diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index 3bd45b2..d2cb523 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -115,8 +115,27 @@ from manual_sltp_lib import ( resolve_entrust_sltp_prices, resolve_open_sltp_prices, ) +from trigger_entry_key_monitor_lib import ( + TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED, + TRIGGER_ENTRY_CLOSE_EXPIRED, + TRIGGER_ENTRY_CLOSE_FILLED, + TRIGGER_ENTRY_CLOSE_TP_INVALIDATE, + TRIGGER_ENTRY_MONITOR_TYPE, + TRIGGER_ENTRY_VALIDITY_HOURS, + check_trigger_entry_intent_limit, + count_pending_trigger_entries, + is_trigger_entry_expired, + is_trigger_entry_key_monitor_type, + trigger_entry_expires_at_text, + trigger_entry_gate_preview, + trigger_entry_invalidate_by_tp, + trigger_entry_reached, + validate_trigger_entry_geometry, + validate_trigger_entry_rr, +) from position_sizing_lib import ( OPEN_SOURCE_KEY_AUTO, + OPEN_SOURCE_KEY_TRIGGER, OPEN_SOURCE_MANUAL, OPEN_SOURCE_ROLL, OPEN_SOURCE_TREND, @@ -1032,6 +1051,7 @@ ENTRY_REASON_OPTIONS = ( "关键位斐波0.618", "关键位斐波0.786", "关键位假突破", + "关键位触价开仓", ) + STRATEGY_ENTRY_REASON_OPTIONS STATS_SEGMENT_DEFS = ( @@ -1042,6 +1062,7 @@ STATS_SEGMENT_DEFS = ( ("key_fib618", "关键位斐波0.618", {"segment": "key_fib618"}), ("key_fib786", "关键位斐波0.786", {"segment": "key_fib786"}), ("key_false_breakout", "关键位假突破", {"segment": "key_false_breakout"}), + ("key_trigger", "关键位触价开仓", {"segment": "key_trigger"}), ) # 复盘表单「其他」选项的 value(非入库值;自定义文本走 entry_reason_custom) ENTRY_REASON_OTHER = "__OTHER__" @@ -1438,6 +1459,7 @@ def init_db(): "ALTER TABLE key_monitors ADD COLUMN manual_take_profit REAL", "ALTER TABLE key_monitors ADD COLUMN breakeven_enabled INTEGER DEFAULT 0", "ALTER TABLE key_monitors ADD COLUMN last_rs_bar_ts INTEGER", + "ALTER TABLE key_monitors ADD COLUMN session_date TEXT", ): try: c.execute(ddl) @@ -1633,6 +1655,8 @@ def _pnl_row_matches_segment(row, segment_key): return kst == "斐波回调0.786" if segment_key == "key_false_breakout": return kst == FALSE_BREAKOUT_MONITOR_TYPE + if segment_key == "key_trigger": + return kst == TRIGGER_ENTRY_MONITOR_TYPE return False @@ -1650,6 +1674,7 @@ def _count_opens_for_segment(conn, start_td, end_td, segment_key): "key_fib618": "斐波回调0.618", "key_fib786": "斐波回调0.786", "key_false_breakout": FALSE_BREAKOUT_MONITOR_TYPE, + "key_trigger": TRIGGER_ENTRY_MONITOR_TYPE, } kst = kst_map.get(segment_key) if kst: @@ -5031,7 +5056,464 @@ def _finalize_fib_key_fill(conn, row): _finalize_key_monitor_one_shot(conn, row, succ, close_reason) -def check_fib_key_monitors(): +def _trigger_entry_exists_for_symbol(conn, symbol): + row = conn.execute( + "SELECT id FROM key_monitors WHERE symbol=? AND monitor_type=?", + (symbol, TRIGGER_ENTRY_MONITOR_TYPE), + ).fetchone() + return row is not None + + +def _add_trigger_entry_key_monitor( + conn, + symbol, + direction_sel, + entry, + sl, + tp, + breakeven_enabled=0, + time_close_enabled=0, + time_close_hours=None, +): + if _trigger_entry_exists_for_symbol(conn, symbol): + return False, f"{symbol} 已有触价开仓监控(同币仅允许一条)" + ex_sym = normalize_exchange_symbol(symbol) + mark = get_symbol_mark_price(symbol) + geom_err = validate_trigger_entry_geometry(direction_sel, entry, sl, tp, mark_at_add=mark) + if geom_err: + return False, geom_err + rr_err = validate_trigger_entry_rr( + direction_sel, entry, sl, tp, KEY_AUTO_MIN_PLANNED_RR, calc_rr_ratio + ) + if rr_err: + return False, rr_err + entry = float(round_price_to_exchange(ex_sym, entry) or entry) + sl = float(round_price_to_exchange(ex_sym, sl) or sl) + tp = float(round_price_to_exchange(ex_sym, tp) or tp) + geom_err = validate_trigger_entry_geometry(direction_sel, entry, sl, tp, mark_at_add=mark) + if geom_err: + return False, geom_err + rr_err = validate_trigger_entry_rr( + direction_sel, entry, sl, tp, KEY_AUTO_MIN_PLANNED_RR, calc_rr_ratio + ) + if rr_err: + return False, rr_err + ok_live, reason_live = ensure_exchange_live_ready() + if not ok_live: + return False, reason_live + now = app_now() + trading_day = get_trading_day(now) + opens_today = count_opens_for_trading_day(conn, trading_day) + ok_intent, intent_msg = check_trigger_entry_intent_limit( + conn, trading_day, opens_today, DAILY_OPEN_HARD_LIMIT + ) + if not ok_intent: + return False, intent_msg + if is_full_margin_mode(POSITION_SIZING_MODE): + ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn)) + if not ok_flat: + return False, flat_msg + if count_pending_trigger_entries(conn, trading_day) > 0: + return False, "全仓杠杆模式下仅允许一条待触发触价监控" + 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) + available_usdt = get_available_trading_usdt() + if is_full_margin_mode(POSITION_SIZING_MODE): + leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE) + sizing, sizing_err = compute_full_margin_sizing( + symbol=symbol, + available_usdt=available_usdt if available_usdt is not None else 0.0, + capital_base=capital_base, + buffer_ratio=FULL_MARGIN_BUFFER_RATIO, + btc_leverage=BTC_LEVERAGE, + alt_leverage=ALT_LEVERAGE, + funds_decimals=2, + ) + if sizing_err: + return False, sizing_err + margin_capital = float(sizing["margin_capital"]) + amount_plan = None + else: + default_leverage = get_synced_leverage(ex_sym, direction_sel) or infer_leverage(symbol) + leverage = int(default_leverage) if default_leverage else 5 + if leverage <= 0: + leverage = 5 + risk_fraction = calc_risk_fraction(direction_sel, entry, sl) + if risk_fraction is None: + return False, "止损方向不合法(相对计划入场价)" + 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, "以损定仓后保证金超过当前交易资金" + 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", + ) + try: + amount_plan, _ = prepare_order_amount(ex_sym, margin_capital, leverage, entry) + except Exception as e: + return False, friendly_exchange_error(e, available_usdt=available_usdt) + upper_px = round_price_to_exchange(ex_sym, max(entry, tp)) + lower_px = round_price_to_exchange(ex_sym, min(entry, sl)) + if upper_px is None or lower_px is None or float(upper_px) <= float(lower_px): + upper_px, lower_px = float(max(entry, tp, sl)), float(min(entry, tp, sl)) + if upper_px <= lower_px: + lower_px = upper_px * 0.9999 + be_flag = 1 if int(breakeven_enabled or 0) != 0 else 0 + tc_en, tc_h, _ = time_close_insert_values(time_close_enabled, time_close_hours, None) + conn.execute( + "INSERT INTO key_monitors " + "(symbol, monitor_type, direction, upper, lower, " + "fib_entry_price, fib_stop_loss, fib_take_profit, " + "fib_order_amount, fib_margin_capital, fib_leverage, breakeven_enabled, " + "time_close_enabled, time_close_hours, session_date) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + TRIGGER_ENTRY_MONITOR_TYPE, + direction_sel, + float(upper_px), + float(lower_px), + entry, + sl, + tp, + float(amount_plan) if amount_plan is not None else None, + margin_capital, + leverage, + be_flag, + tc_en, + tc_h, + trading_day, + ), + ) + return True, None + + +def _market_open_for_trigger_entry( + conn, + symbol, + direction, + exchange_symbol, + entry_price, + stop_loss, + take_profit, + breakeven_enabled=0, + time_close_enabled=0, + time_close_hours=None, +): + """触价触发后市价开仓,计仓规则与实盘下单/关键位 RR 门槛一致。""" + ok_src, src_msg = assert_open_source_allowed(POSITION_SIZING_MODE, OPEN_SOURCE_KEY_TRIGGER) + if not ok_src: + return False, src_msg, None + 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 + + trading_day = get_trading_day(now) + opens_today_before = count_opens_for_trading_day(conn, trading_day) + 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_symbol_mark_price(symbol) or 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 = float(lp_r) + + entry_price = float(entry_price) + 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) + + planned_rr = calc_rr_ratio(direction, entry_price, stop_loss, take_profit) + if planned_rr is None or planned_rr <= KEY_AUTO_MIN_PLANNED_RR: + rr_txt = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算" + return False, f"计划盈亏比 {rr_txt}:1 未达要求(>{KEY_AUTO_MIN_PLANNED_RR}:1)", None + + risk_percent = max(0.01, float(RISK_PERCENT)) + if is_full_margin_mode(POSITION_SIZING_MODE): + ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn)) + if not ok_flat: + return False, flat_msg, None + leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE) + sizing, sizing_err = compute_full_margin_sizing( + symbol=symbol, + available_usdt=available_usdt if available_usdt is not None else 0.0, + capital_base=capital_base, + buffer_ratio=FULL_MARGIN_BUFFER_RATIO, + btc_leverage=BTC_LEVERAGE, + alt_leverage=ALT_LEVERAGE, + funds_decimals=2, + ) + if sizing_err: + return False, sizing_err, None + margin_capital = float(sizing["margin_capital"]) + notional_value = float(sizing["notional_value"]) + position_ratio = float(sizing["position_ratio"]) + risk_amount = margin_capital + else: + 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 + risk_fraction = calc_risk_fraction(direction, entry_price, stop_loss) + if risk_fraction is None: + return False, "止损方向不合法(相对计划入场价)", None + 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_exchange_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_fill = 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 + tc_en, tc_h, tc_at = time_close_insert_values(time_close_enabled, time_close_hours, opened_at_ms) + risk_percent_db = risk_percent_for_storage(POSITION_SIZING_MODE, risk_percent) + + 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, " + "time_close_enabled, time_close_hours, time_close_at_ms) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + exchange_symbol, + direction, + trigger_price, + stop_loss, + stop_loss, + take_profit, + margin_capital, + leverage, + trade_style, + risk_percent_db, + 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(TRIGGER_ENTRY_MONITOR_TYPE), + tc_en, + tc_h, + tc_at, + ), + ) + new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + try_persist_exchange_margin_for_order(conn, new_order_id, exchange_symbol, direction, order_leverage=leverage) + opens_today_after = count_opens_for_trading_day(conn, trading_day) + + return True, None, { + "new_order_id": new_order_id, + "open_order_id": open_order_id, + "trigger_price": trigger_price, + "planned_rr_fill": planned_rr_fill, + "risk_amount_final": risk_amount_final, + "margin_capital": margin_capital, + "leverage": leverage, + "amount": amount, + "tpsl_attached": tpsl_attached, + "opens_today_before": opens_today_before, + "opens_today_after": opens_today_after, + "trading_day": trading_day, + "stop_loss": stop_loss, + "take_profit": take_profit, + } + + +def _execute_trigger_entry_cross(conn, row): + """标记价触达计划入场:先删监控行防重复触发,再市价开仓。""" + symbol = row["symbol"] + direction = (row["direction"] or "long").lower() + ex_sym = normalize_exchange_symbol(symbol) + entry = float(_sqlite_row_val(row, "fib_entry_price") or 0) + sl = float(_sqlite_row_val(row, "fib_stop_loss") or 0) + tp = float(_sqlite_row_val(row, "fib_take_profit") or 0) + be_en = breakeven_enabled_from_row(row, 0) + tc_en, tc_h, _ = time_close_settings_from_row(row) + + kid = int(row["id"]) + conn.execute("DELETE FROM key_monitors WHERE id=?", (kid,)) + conn.commit() + + ok, err, det = _market_open_for_trigger_entry( + conn, + symbol, + direction, + ex_sym, + entry, + sl, + tp, + breakeven_enabled=be_en, + time_close_enabled=tc_en, + time_close_hours=tc_h, + ) + if ok and det: + rr_txt = format_wechat_scalar_2dp(det.get("planned_rr_fill")) if det.get("planned_rr_fill") is not None else "-" + msg = ( + f"# ✅ {symbol} 触价开仓成交\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 来源:{ORDER_MONITOR_TYPE_KEY_AUTO}(程序触价 @ E)\n" + f"- 类型:{TRIGGER_ENTRY_MONITOR_TYPE}|{_wechat_direction_text(direction)}\n" + f"- 订单 ID:**{det.get('new_order_id')}**\n" + f"- 计划入场:{format_price_for_symbol(symbol, entry)}\n" + f"- 成交价:{format_price_for_symbol(symbol, det.get('trigger_price'))}\n" + f"- 止损:{format_wechat_scalar_2dp(det.get('stop_loss'))}|止盈:{format_price_for_symbol(symbol, det.get('take_profit'))}\n" + f"- 计划 RR:{rr_txt}:1\n" + f"- {'已挂交易所 TP/SL' if det.get('tpsl_attached') else 'TP/SL 未挂上'}\n" + ) + send_wechat_msg(msg) + insert_key_monitor_history(conn, row, 0, msg, TRIGGER_ENTRY_CLOSE_FILLED) + return True, None + fail_msg = err or "触价触发后开仓失败" + send_wechat_msg( + f"# ❌ {symbol} 触价开仓失败\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 计划入场:{format_price_for_symbol(symbol, entry)}\n" + f"- 原因:{fail_msg}\n" + ) + insert_key_monitor_history(conn, row, 0, fail_msg, TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED) + return False, fail_msg + + +def check_trigger_entry_key_monitors(): + conn = get_db() + rows = conn.execute("SELECT * FROM key_monitors WHERE monitor_type=?", (TRIGGER_ENTRY_MONITOR_TYPE,)).fetchall() + now_dt = app_now() + for r in rows: + symbol = r["symbol"] + direction = (r["direction"] or "long").lower() + entry = float(_sqlite_row_val(r, "fib_entry_price") or 0) + sl = float(_sqlite_row_val(r, "fib_stop_loss") or 0) + tp = float(_sqlite_row_val(r, "fib_take_profit") or 0) + if entry <= 0 or sl <= 0 or tp <= 0: + _finalize_key_monitor_one_shot(conn, r, "触价计划价位无效", "fib_plan_invalid") + continue + mark = get_symbol_mark_price(symbol) + if mark is None: + continue + if is_trigger_entry_expired(r["created_at"], now_dt, hours=TRIGGER_ENTRY_VALIDITY_HOURS): + exp_txt = trigger_entry_expires_at_text(r["created_at"], hours=TRIGGER_ENTRY_VALIDITY_HOURS) + msg = ( + f"# ⚠️ {symbol} 触价开仓已过期\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{TRIGGER_ENTRY_MONITOR_TYPE}|{_wechat_direction_text(direction)}\n" + f"- 有效期 {TRIGGER_ENTRY_VALIDITY_HOURS}h(应于 {exp_txt} 前触发)\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, TRIGGER_ENTRY_CLOSE_EXPIRED) + continue + if trigger_entry_invalidate_by_tp(direction, mark, tp): + msg = ( + f"# ⚠️ {symbol} 触价开仓失效\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 标记价 {format_price_for_symbol(symbol, mark)} 已触达止盈侧(未成交)\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, TRIGGER_ENTRY_CLOSE_TP_INVALIDATE) + continue + if trigger_entry_reached(direction, mark, entry): + try: + _execute_trigger_entry_cross(conn, r) + except Exception as e: + fail_msg = friendly_exchange_error(e) + try: + insert_key_monitor_history(conn, r, 0, fail_msg, TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED) + except Exception: + pass + send_wechat_msg( + f"# ❌ {symbol} 触价开仓异常\n**账户:{_wechat_account_label()}**\n- {fail_msg}\n" + ) + conn.commit() + conn.close() + + + conn = get_db() rows = conn.execute("SELECT * FROM key_monitors").fetchall() for r in rows: @@ -5915,6 +6397,7 @@ def background_task(): conn.close() force_close_before_reset() check_fib_key_monitors() + check_trigger_entry_key_monitors() _roll_cfg = app.extensions.get("strategy_roll_cfg") if _roll_cfg: from strategy_roll_monitor_lib import check_roll_monitors @@ -6170,6 +6653,7 @@ def render_main_page(page="trade"): key_stop_outside_breakout_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT, key_trend_stop_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT, false_breakout_validity_hours=FALSE_BREAKOUT_VALIDITY_HOURS, + trigger_entry_validity_hours=TRIGGER_ENTRY_VALIDITY_HOURS, ) strategy_extra = {} if page in ("strategy", "strategy_trend", "strategy_roll", "strategy_records"): @@ -6328,7 +6812,7 @@ def api_account_snapshot(): def api_price_snapshot(): conn = get_db() key_rows = conn.execute( - "SELECT id,symbol,monitor_type,direction,upper,lower,fib_entry_price,fib_limit_order_id,created_at FROM key_monitors" + "SELECT id,symbol,monitor_type,direction,upper,lower,fib_entry_price,fib_stop_loss,fib_take_profit,fib_limit_order_id,created_at FROM key_monitors" ).fetchall() order_rows = conn.execute( "SELECT id,symbol,exchange_symbol,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage," @@ -6359,7 +6843,8 @@ def api_price_snapshot(): for r in key_rows: is_fib = is_fib_key_monitor_type(r["monitor_type"]) is_fb = is_false_breakout_key_monitor_type(r["monitor_type"]) - if is_fib or is_fb: + is_te = is_trigger_entry_key_monitor_type(r["monitor_type"]) + if is_fib or is_fb or is_te: price = get_symbol_mark_price(r["symbol"]) else: price = prices.get(r["symbol"]) @@ -6393,6 +6878,24 @@ def api_price_snapshot(): gate_summary = prev.get("summary") or "-" gate_metrics = prev.get("metrics") or "" fb_gate_ok = bool(prev.get("gate_ok")) + elif is_te: + direction = (r["direction"] or "long").lower() + entry = _sqlite_row_val(r, "fib_entry_price") + tp_v = _sqlite_row_val(r, "fib_take_profit") + entry_txt = format_price_for_symbol(r["symbol"], entry) if entry else "-" + tp_txt = format_price_for_symbol(r["symbol"], tp_v) if tp_v else "-" + tp_inv = trigger_entry_invalidate_by_tp(direction, price, float(tp_v)) if tp_v else False + prev = trigger_entry_gate_preview( + entry_display=entry_txt, + take_profit_display=tp_txt, + created_at=_sqlite_row_val(r, "created_at"), + now=app_now(), + tp_invalidated=tp_inv, + hours=TRIGGER_ENTRY_VALIDITY_HOURS, + ) + gate_summary = prev.get("summary") or "-" + gate_metrics = prev.get("metrics") or "" + fib_gate_ok = bool(prev.get("gate_ok")) elif (r["monitor_type"] or "").strip() in KEY_MONITOR_RS_TYPES: try: prev = _key_rs_gate_preview(r["symbol"], r["upper"], r["lower"]) @@ -6967,14 +7470,15 @@ def add_key(): + tuple(KEY_MONITOR_ALERT_ONLY_TYPES) + tuple(FIB_KEY_MONITOR_TYPES) + (FALSE_BREAKOUT_MONITOR_TYPE,) + + (TRIGGER_ENTRY_MONITOR_TYPE,) ) if mt not in allowed_types: flash("监控类型无效") return redirect("/key_monitor") if is_full_margin_mode(POSITION_SIZING_MODE) and monitor_type_disallowed_in_full_margin(mt): flash( - "全仓杠杆模式下不可添加箱体/收敛突破或斐波监控;" - "请改用阻力/支撑(仅提醒),或切换 POSITION_SIZING_MODE=risk 并重启(须无持仓)。" + "全仓杠杆模式下不可添加箱体/收敛突破、斐波或假突破监控;" + "可使用「触价开仓」或阻力/支撑(仅提醒),或切换 POSITION_SIZING_MODE=risk 并重启(须无持仓)。" ) return redirect("/key_monitor") skip_volume_rank = is_false_breakout_key_monitor_type(mt) @@ -7003,6 +7507,45 @@ def add_key(): except Exception: pass be_flag = parse_breakeven_enabled_form(d.get("breakeven_enabled")) + tc_en = parse_time_close_enabled_form(d.get("time_close_enabled")) + tc_h = parse_time_close_hours_form(d.get("time_close_hours")) if tc_en else None + if tc_en and not tc_h: + tc_en = 0 + if is_trigger_entry_key_monitor_type(mt): + if direction_sel not in ("long", "short"): + conn.close() + conn = None + flash("触价开仓请选择做多或做空") + return redirect("/key_monitor") + try: + entry_px = float(d.get("trigger_entry") or 0) + sl_px = float(d.get("trigger_sl") or 0) + tp_px = float(d.get("trigger_tp") or 0) + except (TypeError, ValueError): + entry_px = sl_px = tp_px = 0 + if entry_px <= 0 or sl_px <= 0 or tp_px <= 0: + conn.close() + conn = None + flash("触价开仓须填写有效的入场价、止损价、止盈价") + return redirect("/key_monitor") + ok_te, err_te = _add_trigger_entry_key_monitor( + conn, symbol, direction_sel, entry_px, sl_px, tp_px, breakeven_enabled=be_flag, + time_close_enabled=tc_en, time_close_hours=tc_h, + ) + conn.commit() + conn.close() + conn = None + if not ok_te: + flash(err_te or "触价开仓监控添加失败") + return redirect("/key_monitor") + flash( + f"触价开仓已添加({symbol} 日成交量排名 {rank}/{total})" + f"|有效期 {TRIGGER_ENTRY_VALIDITY_HOURS}h" + f"|标记价触达入场价后下一轮询市价开仓" + f"|移动保本:{'开' if be_flag else '关'}" + + (f"|{time_close_label(tc_h)}" if tc_en else "") + ) + return redirect("/key_monitor") if is_false_breakout_key_monitor_type(mt): fb_sym = normalize_false_breakout_symbol(symbol) if not fb_sym: diff --git a/crypto_monitor_binance/templates/index.html b/crypto_monitor_binance/templates/index.html index 8eef9f6..4f642d1 100644 --- a/crypto_monitor_binance/templates/index.html +++ b/crypto_monitor_binance/templates/index.html @@ -1540,14 +1540,19 @@ function syncKeyMonitorFormFields(){ const autoTypes = new Set(["箱体突破","收敛突破"]); const fibTypes = new Set(["斐波回调0.618","斐波回调0.786"]); const fbTypes = new Set(["假突破"]); + const teTypes = new Set(["触价开仓"]); const rsTypes = new Set(["关键阻力位","关键支撑位"]); const showAuto = autoTypes.has(t); const showFb = fbTypes.has(t); - const showBe = showAuto || fibTypes.has(t) || showFb; + const showTe = teTypes.has(t); + const showBe = showAuto || fibTypes.has(t) || showFb || showTe; const showDir = !rsTypes.has(t); const upperEl = document.getElementById("key-upper"); const lowerEl = document.getElementById("key-lower"); const fbPriceEl = document.getElementById("key-fb-price"); + const teEntryEl = document.getElementById("key-trigger-entry"); + const teSlEl = document.getElementById("key-trigger-sl"); + const teTpEl = document.getElementById("key-trigger-tp"); if(dirEl){ dirEl.style.display = showDir ? "" : "none"; dirEl.required = showDir; @@ -1561,15 +1566,16 @@ function syncKeyMonitorFormFields(){ } if(beWrap) beWrap.style.display = showBe ? "inline-flex" : "none"; if(window.TimeCloseUI) TimeCloseUI.syncKeyTimeCloseVisibility(showBe); + const hideBounds = showFb || showTe; if(upperEl){ - upperEl.style.display = showFb ? "none" : ""; - upperEl.required = !showFb; - if(showFb) upperEl.value = ""; + upperEl.style.display = hideBounds ? "none" : ""; + upperEl.required = !hideBounds; + if(hideBounds) upperEl.value = ""; } if(lowerEl){ - lowerEl.style.display = showFb ? "none" : ""; - lowerEl.required = !showFb; - if(showFb) lowerEl.value = ""; + lowerEl.style.display = hideBounds ? "none" : ""; + lowerEl.required = !hideBounds; + if(hideBounds) lowerEl.value = ""; } if(fbPriceEl){ fbPriceEl.style.display = showFb ? "" : "none"; @@ -1577,6 +1583,12 @@ function syncKeyMonitorFormFields(){ if(!showFb) fbPriceEl.value = ""; fbPriceEl.placeholder = (dirEl && dirEl.value === "short") ? "高点(阻力)" : ((dirEl && dirEl.value === "long") ? "低点(支撑)" : "做空填高点/做多填低点"); } + [teEntryEl, teSlEl, teTpEl].forEach((el)=>{ + if(!el) return; + el.style.display = showTe ? "" : "none"; + el.required = showTe; + if(!showTe) el.value = ""; + }); } const keyTypeSel = document.querySelector('#key-form [name="type"]'); const keyModeSel = document.getElementById("key-sl-tp-mode"); diff --git a/crypto_monitor_binance/使用说明.md b/crypto_monitor_binance/使用说明.md index 0754a37..fe3995e 100644 --- a/crypto_monitor_binance/使用说明.md +++ b/crypto_monitor_binance/使用说明.md @@ -66,9 +66,10 @@ | **收敛突破** | 同上(自动开仓类)。 | | **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**。 | | **关键支撑位** | 同上(仅提醒)。 | + | **触价开仓** | **不挂交易所限价**;标记价触达计划入场价后 **下一轮询市价开仓**(RR 门槛同关键位 `KEY_AUTO_MIN_PLANNED_RR`);有效期 **24h**;全仓杠杆模式可用。 | -3. **方向**:做多 / 做空(必选)。 -4. **上沿 / 下沿**:必填;保存时会按交易所 **价格精度** 取整。 +3. **方向**:做多 / 做空(触价开仓 / 箱体 / 收敛 / 斐波必选;阻力/支撑不选)。 +4. **价位**:箱体/收敛/阻力/支撑填 **上沿 / 下沿**;触价开仓填 **入场 E / 止损 SL / 止盈 TP**。 **限制:** 活跃持仓数达到 **`MAX_ACTIVE_POSITIONS`**(默认 1)时,**不允许**再添加「**箱体突破** / **收敛突破**」;仍可添加「**关键阻力位 / 支撑位**」。 diff --git a/crypto_monitor_binance/关键位自动下单说明.md b/crypto_monitor_binance/关键位自动下单说明.md index 87d98ff..c352af0 100644 --- a/crypto_monitor_binance/关键位自动下单说明.md +++ b/crypto_monitor_binance/关键位自动下单说明.md @@ -1,7 +1,7 @@ # 关键位监控说明(自动开仓 + 人工盯盘) -**适用:`crypto_monitor_binance`(Binance U 本位永续)** -Gate / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `key_monitor_lib.py`。 +**适用:`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` 一致。 @@ -16,8 +16,9 @@ Gate / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `key_ | **关键阻力位** | **不选**(`direction=watch`) | **否** | 5m 收盘突破上/下沿 → 微信 **3 次** → `key_level_alert_done` | | **关键支撑位** | **不选** | **否** | 同上(与阻力位**相同规则**:填上沿+下沿,程序双向监控) | | 斐波回调 0.618 / 0.786 | 必选 | 限价挂单逻辑 | 见斐波说明(**不在下文展开**) | +| **触价开仓** | **必选** 多/空 | **程序盯价 → 触 E 后市价** | 见下文 **§六** | -**添加时(所有类型):** 品种须 **日成交量排名前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30)**;上沿 **>** 下沿。 +**添加时(箱体/收敛/斐波/触价):** 品种须 **日成交量排名前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30)**;上沿 **>** 下沿(触价开仓填 E/SL/TP,上下沿仅作展示占位)。 --- @@ -117,7 +118,31 @@ Gate / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `key_ --- -## 四、环境与参数(`.env` 摘要) +## 四、触价开仓(程序触价,无交易所挂单) + +### 4.1 录入 + +- 类型选 **触价开仓**;方向必选多/空。 +- 填写 **计划入场价 E**、**止损 SL**、**止盈 TP**(做多须 `SL < E < TP`)。 +- 计划 RR 以 **E** 为基准,须 **严格大于** `KEY_AUTO_MIN_PLANNED_RR`(默认 1.5,与箱体/斐波相同)。 +- 可选移动保本、时间平仓;**全仓杠杆模式**下可用(页面隐藏箱体/收敛/斐波/假突破)。 + +### 4.2 触发与结案 + +- 轮询标记价:做多 `标记价 ≤ E`、做空 `标记价 ≥ E` → **下一轮询市价开仓**,挂交易所 TP/SL,进下单监控。 +- 未成交前标记价先触 **TP 侧** → `trigger_tp_invalidate`;**24h** 未触发 → `trigger_entry_expired`。 +- 成功 → `trigger_entry_filled`;触发后开仓失败 → `trigger_exchange_failed`。 + +### 4.3 计仓与占位 + +- **以损定仓**:按 E、SL 反推保证金,触发时重算;**全仓杠杆**:可用×缓冲比例,BTC/ETH 10x、其它 5x。 +- **占当日开仓意图**(已开 + 待触发),未成交不占持仓;同币仅 1 条。 + +共享逻辑:`trigger_entry_key_monitor_lib.py`;轮询:`check_trigger_entry_key_monitors`。 + +--- + +## 五、环境与参数(`.env` 摘要) | 变量 | 箱体/收敛 | 阻力/支撑 | |------|-----------|-----------| @@ -130,7 +155,7 @@ Gate / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `key_ --- -## 五、相关代码 +## 六、相关代码 | 说明 | 位置 | |------|------| diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py index 3a35988..72b7505 100644 --- a/crypto_monitor_gate/app.py +++ b/crypto_monitor_gate/app.py @@ -116,8 +116,27 @@ from manual_sltp_lib import ( resolve_entrust_sltp_prices, resolve_open_sltp_prices, ) +from trigger_entry_key_monitor_lib import ( + TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED, + TRIGGER_ENTRY_CLOSE_EXPIRED, + TRIGGER_ENTRY_CLOSE_FILLED, + TRIGGER_ENTRY_CLOSE_TP_INVALIDATE, + TRIGGER_ENTRY_MONITOR_TYPE, + TRIGGER_ENTRY_VALIDITY_HOURS, + check_trigger_entry_intent_limit, + count_pending_trigger_entries, + is_trigger_entry_expired, + is_trigger_entry_key_monitor_type, + trigger_entry_expires_at_text, + trigger_entry_gate_preview, + trigger_entry_invalidate_by_tp, + trigger_entry_reached, + validate_trigger_entry_geometry, + validate_trigger_entry_rr, +) from position_sizing_lib import ( OPEN_SOURCE_KEY_AUTO, + OPEN_SOURCE_KEY_TRIGGER, OPEN_SOURCE_MANUAL, assert_open_source_allowed, compute_full_margin_sizing, @@ -1022,6 +1041,7 @@ ENTRY_REASON_OPTIONS = ( "关键位斐波0.618", "关键位斐波0.786", "关键位假突破", + "关键位触价开仓", ) + STRATEGY_ENTRY_REASON_OPTIONS STATS_SEGMENT_DEFS = ( @@ -1032,6 +1052,7 @@ STATS_SEGMENT_DEFS = ( ("key_fib618", "关键位斐波0.618", {"segment": "key_fib618"}), ("key_fib786", "关键位斐波0.786", {"segment": "key_fib786"}), ("key_false_breakout", "关键位假突破", {"segment": "key_false_breakout"}), + ("key_trigger", "关键位触价开仓", {"segment": "key_trigger"}), ) # 复盘表单「其他」选项的 value(非入库值;自定义文本走 entry_reason_custom) ENTRY_REASON_OTHER = "__OTHER__" @@ -1433,6 +1454,7 @@ def init_db(): "ALTER TABLE key_monitors ADD COLUMN manual_take_profit REAL", "ALTER TABLE key_monitors ADD COLUMN breakeven_enabled INTEGER DEFAULT 0", "ALTER TABLE key_monitors ADD COLUMN last_rs_bar_ts INTEGER", + "ALTER TABLE key_monitors ADD COLUMN session_date TEXT", ): try: c.execute(ddl) @@ -1630,6 +1652,8 @@ def _pnl_row_matches_segment(row, segment_key): return kst == "斐波回调0.786" if segment_key == "key_false_breakout": return kst == FALSE_BREAKOUT_MONITOR_TYPE + if segment_key == "key_trigger": + return kst == TRIGGER_ENTRY_MONITOR_TYPE return False @@ -1647,6 +1671,7 @@ def _count_opens_for_segment(conn, start_td, end_td, segment_key): "key_fib618": "斐波回调0.618", "key_fib786": "斐波回调0.786", "key_false_breakout": FALSE_BREAKOUT_MONITOR_TYPE, + "key_trigger": TRIGGER_ENTRY_MONITOR_TYPE, } kst = kst_map.get(segment_key) if kst: @@ -5001,6 +5026,463 @@ def _finalize_fib_key_fill(conn, row): _finalize_key_monitor_one_shot(conn, row, succ, close_reason) +def _trigger_entry_exists_for_symbol(conn, symbol): + row = conn.execute( + "SELECT id FROM key_monitors WHERE symbol=? AND monitor_type=?", + (symbol, TRIGGER_ENTRY_MONITOR_TYPE), + ).fetchone() + return row is not None + + +def _add_trigger_entry_key_monitor( + conn, + symbol, + direction_sel, + entry, + sl, + tp, + breakeven_enabled=0, + time_close_enabled=0, + time_close_hours=None, +): + if _trigger_entry_exists_for_symbol(conn, symbol): + return False, f"{symbol} 已有触价开仓监控(同币仅允许一条)" + ex_sym = normalize_exchange_symbol(symbol) + mark = get_symbol_mark_price(symbol) + geom_err = validate_trigger_entry_geometry(direction_sel, entry, sl, tp, mark_at_add=mark) + if geom_err: + return False, geom_err + rr_err = validate_trigger_entry_rr( + direction_sel, entry, sl, tp, KEY_AUTO_MIN_PLANNED_RR, calc_rr_ratio + ) + if rr_err: + return False, rr_err + entry = float(round_price_to_exchange(ex_sym, entry) or entry) + sl = float(round_price_to_exchange(ex_sym, sl) or sl) + tp = float(round_price_to_exchange(ex_sym, tp) or tp) + geom_err = validate_trigger_entry_geometry(direction_sel, entry, sl, tp, mark_at_add=mark) + if geom_err: + return False, geom_err + rr_err = validate_trigger_entry_rr( + direction_sel, entry, sl, tp, KEY_AUTO_MIN_PLANNED_RR, calc_rr_ratio + ) + if rr_err: + return False, rr_err + ok_live, reason_live = ensure_exchange_live_ready() + if not ok_live: + return False, reason_live + now = app_now() + trading_day = get_trading_day(now) + opens_today = count_opens_for_trading_day(conn, trading_day) + ok_intent, intent_msg = check_trigger_entry_intent_limit( + conn, trading_day, opens_today, DAILY_OPEN_HARD_LIMIT + ) + if not ok_intent: + return False, intent_msg + if is_full_margin_mode(POSITION_SIZING_MODE): + ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn)) + if not ok_flat: + return False, flat_msg + if count_pending_trigger_entries(conn, trading_day) > 0: + return False, "全仓杠杆模式下仅允许一条待触发触价监控" + 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) + available_usdt = get_available_trading_usdt() + if is_full_margin_mode(POSITION_SIZING_MODE): + leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE) + sizing, sizing_err = compute_full_margin_sizing( + symbol=symbol, + available_usdt=available_usdt if available_usdt is not None else 0.0, + capital_base=capital_base, + buffer_ratio=FULL_MARGIN_BUFFER_RATIO, + btc_leverage=BTC_LEVERAGE, + alt_leverage=ALT_LEVERAGE, + funds_decimals=2, + ) + if sizing_err: + return False, sizing_err + margin_capital = float(sizing["margin_capital"]) + amount_plan = None + else: + default_leverage = get_synced_leverage(ex_sym, direction_sel) or infer_leverage(symbol) + leverage = int(default_leverage) if default_leverage else 5 + if leverage <= 0: + leverage = 5 + risk_fraction = calc_risk_fraction(direction_sel, entry, sl) + if risk_fraction is None: + return False, "止损方向不合法(相对计划入场价)" + 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, "以损定仓后保证金超过当前交易资金" + 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", + ) + try: + amount_plan, _ = prepare_order_amount(ex_sym, margin_capital, leverage, entry) + except Exception as e: + return False, friendly_exchange_error(e, available_usdt=available_usdt) + upper_px = round_price_to_exchange(ex_sym, max(entry, tp)) + lower_px = round_price_to_exchange(ex_sym, min(entry, sl)) + if upper_px is None or lower_px is None or float(upper_px) <= float(lower_px): + upper_px, lower_px = float(max(entry, tp, sl)), float(min(entry, tp, sl)) + if upper_px <= lower_px: + lower_px = upper_px * 0.9999 + be_flag = 1 if int(breakeven_enabled or 0) != 0 else 0 + tc_en, tc_h, _ = time_close_insert_values(time_close_enabled, time_close_hours, None) + conn.execute( + "INSERT INTO key_monitors " + "(symbol, monitor_type, direction, upper, lower, " + "fib_entry_price, fib_stop_loss, fib_take_profit, " + "fib_order_amount, fib_margin_capital, fib_leverage, breakeven_enabled, " + "time_close_enabled, time_close_hours, session_date) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + TRIGGER_ENTRY_MONITOR_TYPE, + direction_sel, + float(upper_px), + float(lower_px), + entry, + sl, + tp, + float(amount_plan) if amount_plan is not None else None, + margin_capital, + leverage, + be_flag, + tc_en, + tc_h, + trading_day, + ), + ) + return True, None + + +def _market_open_for_trigger_entry( + conn, + symbol, + direction, + exchange_symbol, + entry_price, + stop_loss, + take_profit, + breakeven_enabled=0, + time_close_enabled=0, + time_close_hours=None, +): + """触价触发后市价开仓,计仓规则与实盘下单/关键位 RR 门槛一致。""" + ok_src, src_msg = assert_open_source_allowed(POSITION_SIZING_MODE, OPEN_SOURCE_KEY_TRIGGER) + if not ok_src: + return False, src_msg, None + 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 + + trading_day = get_trading_day(now) + opens_today_before = count_opens_for_trading_day(conn, trading_day) + 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_symbol_mark_price(symbol) or 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 = float(lp_r) + + entry_price = float(entry_price) + 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) + + planned_rr = calc_rr_ratio(direction, entry_price, stop_loss, take_profit) + if planned_rr is None or planned_rr <= KEY_AUTO_MIN_PLANNED_RR: + rr_txt = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算" + return False, f"计划盈亏比 {rr_txt}:1 未达要求(>{KEY_AUTO_MIN_PLANNED_RR}:1)", None + + risk_percent = max(0.01, float(RISK_PERCENT)) + if is_full_margin_mode(POSITION_SIZING_MODE): + ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn)) + if not ok_flat: + return False, flat_msg, None + leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE) + sizing, sizing_err = compute_full_margin_sizing( + symbol=symbol, + available_usdt=available_usdt if available_usdt is not None else 0.0, + capital_base=capital_base, + buffer_ratio=FULL_MARGIN_BUFFER_RATIO, + btc_leverage=BTC_LEVERAGE, + alt_leverage=ALT_LEVERAGE, + funds_decimals=2, + ) + if sizing_err: + return False, sizing_err, None + margin_capital = float(sizing["margin_capital"]) + notional_value = float(sizing["notional_value"]) + position_ratio = float(sizing["position_ratio"]) + risk_amount = margin_capital + else: + 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 + risk_fraction = calc_risk_fraction(direction, entry_price, stop_loss) + if risk_fraction is None: + return False, "止损方向不合法(相对计划入场价)", None + 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_exchange_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_fill = 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 + tc_en, tc_h, tc_at = time_close_insert_values(time_close_enabled, time_close_hours, opened_at_ms) + risk_percent_db = risk_percent_for_storage(POSITION_SIZING_MODE, risk_percent) + + 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, " + "time_close_enabled, time_close_hours, time_close_at_ms) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + exchange_symbol, + direction, + trigger_price, + stop_loss, + stop_loss, + take_profit, + margin_capital, + leverage, + trade_style, + risk_percent_db, + 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(TRIGGER_ENTRY_MONITOR_TYPE), + tc_en, + tc_h, + tc_at, + ), + ) + new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + try_persist_exchange_margin_for_order(conn, new_order_id, exchange_symbol, direction, order_leverage=leverage) + opens_today_after = count_opens_for_trading_day(conn, trading_day) + + return True, None, { + "new_order_id": new_order_id, + "open_order_id": open_order_id, + "trigger_price": trigger_price, + "planned_rr_fill": planned_rr_fill, + "risk_amount_final": risk_amount_final, + "margin_capital": margin_capital, + "leverage": leverage, + "amount": amount, + "tpsl_attached": tpsl_attached, + "opens_today_before": opens_today_before, + "opens_today_after": opens_today_after, + "trading_day": trading_day, + "stop_loss": stop_loss, + "take_profit": take_profit, + } + + +def _execute_trigger_entry_cross(conn, row): + """标记价触达计划入场:先删监控行防重复触发,再市价开仓。""" + symbol = row["symbol"] + direction = (row["direction"] or "long").lower() + ex_sym = normalize_exchange_symbol(symbol) + entry = float(_sqlite_row_val(row, "fib_entry_price") or 0) + sl = float(_sqlite_row_val(row, "fib_stop_loss") or 0) + tp = float(_sqlite_row_val(row, "fib_take_profit") or 0) + be_en = breakeven_enabled_from_row(row, 0) + tc_en, tc_h, _ = time_close_settings_from_row(row) + + kid = int(row["id"]) + conn.execute("DELETE FROM key_monitors WHERE id=?", (kid,)) + conn.commit() + + ok, err, det = _market_open_for_trigger_entry( + conn, + symbol, + direction, + ex_sym, + entry, + sl, + tp, + breakeven_enabled=be_en, + time_close_enabled=tc_en, + time_close_hours=tc_h, + ) + if ok and det: + rr_txt = format_wechat_scalar_2dp(det.get("planned_rr_fill")) if det.get("planned_rr_fill") is not None else "-" + msg = ( + f"# ✅ {symbol} 触价开仓成交\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 来源:{ORDER_MONITOR_TYPE_KEY_AUTO}(程序触价 @ E)\n" + f"- 类型:{TRIGGER_ENTRY_MONITOR_TYPE}|{_wechat_direction_text(direction)}\n" + f"- 订单 ID:**{det.get('new_order_id')}**\n" + f"- 计划入场:{format_price_for_symbol(symbol, entry)}\n" + f"- 成交价:{format_price_for_symbol(symbol, det.get('trigger_price'))}\n" + f"- 止损:{format_wechat_scalar_2dp(det.get('stop_loss'))}|止盈:{format_price_for_symbol(symbol, det.get('take_profit'))}\n" + f"- 计划 RR:{rr_txt}:1\n" + f"- {'已挂交易所 TP/SL' if det.get('tpsl_attached') else 'TP/SL 未挂上'}\n" + ) + send_wechat_msg(msg) + insert_key_monitor_history(conn, row, 0, msg, TRIGGER_ENTRY_CLOSE_FILLED) + return True, None + fail_msg = err or "触价触发后开仓失败" + send_wechat_msg( + f"# ❌ {symbol} 触价开仓失败\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 计划入场:{format_price_for_symbol(symbol, entry)}\n" + f"- 原因:{fail_msg}\n" + ) + insert_key_monitor_history(conn, row, 0, fail_msg, TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED) + return False, fail_msg + + +def check_trigger_entry_key_monitors(): + conn = get_db() + rows = conn.execute("SELECT * FROM key_monitors WHERE monitor_type=?", (TRIGGER_ENTRY_MONITOR_TYPE,)).fetchall() + now_dt = app_now() + for r in rows: + symbol = r["symbol"] + direction = (r["direction"] or "long").lower() + entry = float(_sqlite_row_val(r, "fib_entry_price") or 0) + sl = float(_sqlite_row_val(r, "fib_stop_loss") or 0) + tp = float(_sqlite_row_val(r, "fib_take_profit") or 0) + if entry <= 0 or sl <= 0 or tp <= 0: + _finalize_key_monitor_one_shot(conn, r, "触价计划价位无效", "fib_plan_invalid") + continue + mark = get_symbol_mark_price(symbol) + if mark is None: + continue + if is_trigger_entry_expired(r["created_at"], now_dt, hours=TRIGGER_ENTRY_VALIDITY_HOURS): + exp_txt = trigger_entry_expires_at_text(r["created_at"], hours=TRIGGER_ENTRY_VALIDITY_HOURS) + msg = ( + f"# ⚠️ {symbol} 触价开仓已过期\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{TRIGGER_ENTRY_MONITOR_TYPE}|{_wechat_direction_text(direction)}\n" + f"- 有效期 {TRIGGER_ENTRY_VALIDITY_HOURS}h(应于 {exp_txt} 前触发)\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, TRIGGER_ENTRY_CLOSE_EXPIRED) + continue + if trigger_entry_invalidate_by_tp(direction, mark, tp): + msg = ( + f"# ⚠️ {symbol} 触价开仓失效\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 标记价 {format_price_for_symbol(symbol, mark)} 已触达止盈侧(未成交)\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, TRIGGER_ENTRY_CLOSE_TP_INVALIDATE) + continue + if trigger_entry_reached(direction, mark, entry): + try: + _execute_trigger_entry_cross(conn, r) + except Exception as e: + fail_msg = friendly_exchange_error(e) + try: + insert_key_monitor_history(conn, r, 0, fail_msg, TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED) + except Exception: + pass + send_wechat_msg( + f"# ❌ {symbol} 触价开仓异常\n**账户:{_wechat_account_label()}**\n- {fail_msg}\n" + ) + conn.commit() + conn.close() + + def check_fib_key_monitors(): conn = get_db() rows = conn.execute("SELECT * FROM key_monitors").fetchall() @@ -5879,6 +6361,7 @@ def background_task(): conn.close() force_close_before_reset() check_fib_key_monitors() + check_trigger_entry_key_monitors() _roll_cfg = app.extensions.get("strategy_roll_cfg") if _roll_cfg: from strategy_roll_monitor_lib import check_roll_monitors @@ -6276,6 +6759,7 @@ def render_main_page(page="trade"): key_stop_outside_breakout_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT, key_trend_stop_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT, false_breakout_validity_hours=FALSE_BREAKOUT_VALIDITY_HOURS, + trigger_entry_validity_hours=TRIGGER_ENTRY_VALIDITY_HOURS, ) strategy_extra = {} if page in ("strategy", "strategy_trend", "strategy_roll", "strategy_records"): @@ -6451,7 +6935,7 @@ def api_account_snapshot(): def api_price_snapshot(): conn = get_db() key_rows = conn.execute( - "SELECT id,symbol,monitor_type,direction,upper,lower,fib_entry_price,fib_limit_order_id,created_at FROM key_monitors" + "SELECT id,symbol,monitor_type,direction,upper,lower,fib_entry_price,fib_stop_loss,fib_take_profit,fib_limit_order_id,created_at FROM key_monitors" ).fetchall() order_rows = conn.execute( "SELECT id,symbol,exchange_symbol,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage," @@ -6491,7 +6975,8 @@ def api_price_snapshot(): for r in key_rows: is_fib = is_fib_key_monitor_type(r["monitor_type"]) is_fb = is_false_breakout_key_monitor_type(r["monitor_type"]) - if is_fib or is_fb: + is_te = is_trigger_entry_key_monitor_type(r["monitor_type"]) + if is_fib or is_fb or is_te: price = get_symbol_mark_price(r["symbol"]) else: price = prices.get(r["symbol"]) @@ -6525,6 +7010,24 @@ def api_price_snapshot(): gate_summary = prev.get("summary") or "-" gate_metrics = prev.get("metrics") or "" fb_gate_ok = bool(prev.get("gate_ok")) + elif is_te: + direction = (r["direction"] or "long").lower() + entry = _sqlite_row_val(r, "fib_entry_price") + tp_v = _sqlite_row_val(r, "fib_take_profit") + entry_txt = format_price_for_symbol(r["symbol"], entry) if entry else "-" + tp_txt = format_price_for_symbol(r["symbol"], tp_v) if tp_v else "-" + tp_inv = trigger_entry_invalidate_by_tp(direction, price, float(tp_v)) if tp_v else False + prev = trigger_entry_gate_preview( + entry_display=entry_txt, + take_profit_display=tp_txt, + created_at=_sqlite_row_val(r, "created_at"), + now=app_now(), + tp_invalidated=tp_inv, + hours=TRIGGER_ENTRY_VALIDITY_HOURS, + ) + gate_summary = prev.get("summary") or "-" + gate_metrics = prev.get("metrics") or "" + fib_gate_ok = bool(prev.get("gate_ok")) elif (r["monitor_type"] or "").strip() in KEY_MONITOR_RS_TYPES: try: prev = _key_rs_gate_preview(r["symbol"], r["upper"], r["lower"]) @@ -7123,14 +7626,15 @@ def add_key(): + tuple(KEY_MONITOR_ALERT_ONLY_TYPES) + tuple(FIB_KEY_MONITOR_TYPES) + (FALSE_BREAKOUT_MONITOR_TYPE,) + + (TRIGGER_ENTRY_MONITOR_TYPE,) ) if mt not in allowed_types: flash("监控类型无效") return redirect("/key_monitor") if is_full_margin_mode(POSITION_SIZING_MODE) and monitor_type_disallowed_in_full_margin(mt): flash( - "全仓杠杆模式下不可添加箱体/收敛突破、斐波或假突破监控;" - "请改用阻力/支撑(仅提醒),或切换 POSITION_SIZING_MODE=risk 并重启(须无持仓)。" + "全仓杠杆模式下不可添加箱体/收敛突破、斐波或假突破监控;" + "可使用「触价开仓」或阻力/支撑(仅提醒),或切换 POSITION_SIZING_MODE=risk 并重启(须无持仓)。" ) return redirect("/key_monitor") skip_volume_rank = is_false_breakout_key_monitor_type(mt) @@ -7166,6 +7670,41 @@ def add_key(): tc_h = parse_time_close_hours_form(d.get("time_close_hours")) if tc_en else None if tc_en and not tc_h: tc_en = 0 + if is_trigger_entry_key_monitor_type(mt): + if direction_sel not in ("long", "short"): + conn.close() + conn = None + flash("触价开仓请选择做多或做空") + return redirect("/key_monitor") + try: + entry_px = float(d.get("trigger_entry") or 0) + sl_px = float(d.get("trigger_sl") or 0) + tp_px = float(d.get("trigger_tp") or 0) + except (TypeError, ValueError): + entry_px = sl_px = tp_px = 0 + if entry_px <= 0 or sl_px <= 0 or tp_px <= 0: + conn.close() + conn = None + flash("触价开仓须填写有效的入场价、止损价、止盈价") + return redirect("/key_monitor") + ok_te, err_te = _add_trigger_entry_key_monitor( + conn, symbol, direction_sel, entry_px, sl_px, tp_px, breakeven_enabled=be_flag, + time_close_enabled=tc_en, time_close_hours=tc_h, + ) + conn.commit() + conn.close() + conn = None + if not ok_te: + flash(err_te or "触价开仓监控添加失败") + return redirect("/key_monitor") + flash( + f"触价开仓已添加({symbol} 日成交量排名 {rank}/{total})" + f"|有效期 {TRIGGER_ENTRY_VALIDITY_HOURS}h" + f"|标记价触达入场价后下一轮询市价开仓" + f"|移动保本:{'开' if be_flag else '关'}" + + (f"|{time_close_label(tc_h)}" if tc_en else "") + ) + return redirect("/key_monitor") if is_false_breakout_key_monitor_type(mt): fb_sym = normalize_false_breakout_symbol(symbol) if not fb_sym: diff --git a/crypto_monitor_gate/templates/index.html b/crypto_monitor_gate/templates/index.html index 6407b29..95af7a1 100644 --- a/crypto_monitor_gate/templates/index.html +++ b/crypto_monitor_gate/templates/index.html @@ -1520,14 +1520,19 @@ function syncKeyMonitorFormFields(){ const autoTypes = new Set(["箱体突破","收敛突破"]); const fibTypes = new Set(["斐波回调0.618","斐波回调0.786"]); const fbTypes = new Set(["假突破"]); + const teTypes = new Set(["触价开仓"]); const rsTypes = new Set(["关键阻力位","关键支撑位"]); const showAuto = autoTypes.has(t); const showFb = fbTypes.has(t); - const showBe = showAuto || fibTypes.has(t) || showFb; + const showTe = teTypes.has(t); + const showBe = showAuto || fibTypes.has(t) || showFb || showTe; const showDir = !rsTypes.has(t); const upperEl = document.getElementById("key-upper"); const lowerEl = document.getElementById("key-lower"); const fbPriceEl = document.getElementById("key-fb-price"); + const teEntryEl = document.getElementById("key-trigger-entry"); + const teSlEl = document.getElementById("key-trigger-sl"); + const teTpEl = document.getElementById("key-trigger-tp"); if(dirEl){ dirEl.style.display = showDir ? "" : "none"; dirEl.required = showDir; @@ -1541,15 +1546,16 @@ function syncKeyMonitorFormFields(){ } if(beWrap) beWrap.style.display = showBe ? "inline-flex" : "none"; if(window.TimeCloseUI) TimeCloseUI.syncKeyTimeCloseVisibility(showBe); + const hideBounds = showFb || showTe; if(upperEl){ - upperEl.style.display = showFb ? "none" : ""; - upperEl.required = !showFb; - if(showFb) upperEl.value = ""; + upperEl.style.display = hideBounds ? "none" : ""; + upperEl.required = !hideBounds; + if(hideBounds) upperEl.value = ""; } if(lowerEl){ - lowerEl.style.display = showFb ? "none" : ""; - lowerEl.required = !showFb; - if(showFb) lowerEl.value = ""; + lowerEl.style.display = hideBounds ? "none" : ""; + lowerEl.required = !hideBounds; + if(hideBounds) lowerEl.value = ""; } if(fbPriceEl){ fbPriceEl.style.display = showFb ? "" : "none"; @@ -1557,6 +1563,12 @@ function syncKeyMonitorFormFields(){ if(!showFb) fbPriceEl.value = ""; fbPriceEl.placeholder = (dirEl && dirEl.value === "short") ? "高点(阻力)" : ((dirEl && dirEl.value === "long") ? "低点(支撑)" : "做空填高点/做多填低点"); } + [teEntryEl, teSlEl, teTpEl].forEach((el)=>{ + if(!el) return; + el.style.display = showTe ? "" : "none"; + el.required = showTe; + if(!showTe) el.value = ""; + }); } const keyTypeSel = document.querySelector('#key-form [name="type"]'); const keyModeSel = document.getElementById("key-sl-tp-mode"); diff --git a/crypto_monitor_gate/使用说明.md b/crypto_monitor_gate/使用说明.md index 7153139..714dbd3 100644 --- a/crypto_monitor_gate/使用说明.md +++ b/crypto_monitor_gate/使用说明.md @@ -65,9 +65,10 @@ | **收敛突破** | 同上(自动开仓类)。 | | **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**。 | | **关键支撑位** | 同上(仅提醒)。 | + | **触价开仓** | **不挂交易所限价**;标记价触达计划入场价后 **下一轮询市价开仓**(RR 门槛同关键位 `KEY_AUTO_MIN_PLANNED_RR`);有效期 **24h**;全仓杠杆模式可用。 | -3. **方向**:做多 / 做空(必选)。 -4. **上沿 / 下沿**:必填;保存时会按交易所 **价格精度** 取整。 +3. **方向**:做多 / 做空(触价开仓 / 箱体 / 收敛 / 斐波必选;阻力/支撑不选)。 +4. **价位**:箱体/收敛/阻力/支撑填 **上沿 / 下沿**;触价开仓填 **入场 E / 止损 SL / 止盈 TP**。 **限制:** 活跃持仓数达到 **`MAX_ACTIVE_POSITIONS`**(默认 1)时,**不允许**再添加「**箱体突破** / **收敛突破**」;仍可添加「**关键阻力位 / 支撑位**」。 diff --git a/crypto_monitor_gate/关键位自动下单说明.md b/crypto_monitor_gate/关键位自动下单说明.md index 50dfb3d..c352af0 100644 --- a/crypto_monitor_gate/关键位自动下单说明.md +++ b/crypto_monitor_gate/关键位自动下单说明.md @@ -16,8 +16,9 @@ Binance / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `k | **关键阻力位** | **不选**(`direction=watch`) | **否** | 5m 收盘突破上/下沿 → 微信 **3 次** → `key_level_alert_done` | | **关键支撑位** | **不选** | **否** | 同上(与阻力位**相同规则**:填上沿+下沿,程序双向监控) | | 斐波回调 0.618 / 0.786 | 必选 | 限价挂单逻辑 | 见斐波说明(**不在下文展开**) | +| **触价开仓** | **必选** 多/空 | **程序盯价 → 触 E 后市价** | 见下文 **§六** | -**添加时(所有类型):** 品种须 **日成交量排名前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30)**;上沿 **>** 下沿。 +**添加时(箱体/收敛/斐波/触价):** 品种须 **日成交量排名前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30)**;上沿 **>** 下沿(触价开仓填 E/SL/TP,上下沿仅作展示占位)。 --- @@ -117,7 +118,31 @@ Binance / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `k --- -## 四、环境与参数(`.env` 摘要) +## 四、触价开仓(程序触价,无交易所挂单) + +### 4.1 录入 + +- 类型选 **触价开仓**;方向必选多/空。 +- 填写 **计划入场价 E**、**止损 SL**、**止盈 TP**(做多须 `SL < E < TP`)。 +- 计划 RR 以 **E** 为基准,须 **严格大于** `KEY_AUTO_MIN_PLANNED_RR`(默认 1.5,与箱体/斐波相同)。 +- 可选移动保本、时间平仓;**全仓杠杆模式**下可用(页面隐藏箱体/收敛/斐波/假突破)。 + +### 4.2 触发与结案 + +- 轮询标记价:做多 `标记价 ≤ E`、做空 `标记价 ≥ E` → **下一轮询市价开仓**,挂交易所 TP/SL,进下单监控。 +- 未成交前标记价先触 **TP 侧** → `trigger_tp_invalidate`;**24h** 未触发 → `trigger_entry_expired`。 +- 成功 → `trigger_entry_filled`;触发后开仓失败 → `trigger_exchange_failed`。 + +### 4.3 计仓与占位 + +- **以损定仓**:按 E、SL 反推保证金,触发时重算;**全仓杠杆**:可用×缓冲比例,BTC/ETH 10x、其它 5x。 +- **占当日开仓意图**(已开 + 待触发),未成交不占持仓;同币仅 1 条。 + +共享逻辑:`trigger_entry_key_monitor_lib.py`;轮询:`check_trigger_entry_key_monitors`。 + +--- + +## 五、环境与参数(`.env` 摘要) | 变量 | 箱体/收敛 | 阻力/支撑 | |------|-----------|-----------| @@ -130,7 +155,7 @@ Binance / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `k --- -## 五、相关代码 +## 六、相关代码 | 说明 | 位置 | |------|------| diff --git a/crypto_monitor_gate_bot/app.py b/crypto_monitor_gate_bot/app.py index b4333d5..2dd9b07 100644 --- a/crypto_monitor_gate_bot/app.py +++ b/crypto_monitor_gate_bot/app.py @@ -116,8 +116,27 @@ from manual_sltp_lib import ( resolve_entrust_sltp_prices, resolve_open_sltp_prices, ) +from trigger_entry_key_monitor_lib import ( + TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED, + TRIGGER_ENTRY_CLOSE_EXPIRED, + TRIGGER_ENTRY_CLOSE_FILLED, + TRIGGER_ENTRY_CLOSE_TP_INVALIDATE, + TRIGGER_ENTRY_MONITOR_TYPE, + TRIGGER_ENTRY_VALIDITY_HOURS, + check_trigger_entry_intent_limit, + count_pending_trigger_entries, + is_trigger_entry_expired, + is_trigger_entry_key_monitor_type, + trigger_entry_expires_at_text, + trigger_entry_gate_preview, + trigger_entry_invalidate_by_tp, + trigger_entry_reached, + validate_trigger_entry_geometry, + validate_trigger_entry_rr, +) from position_sizing_lib import ( OPEN_SOURCE_KEY_AUTO, + OPEN_SOURCE_KEY_TRIGGER, OPEN_SOURCE_MANUAL, assert_open_source_allowed, compute_full_margin_sizing, @@ -1022,6 +1041,7 @@ ENTRY_REASON_OPTIONS = ( "关键位斐波0.618", "关键位斐波0.786", "关键位假突破", + "关键位触价开仓", ) + STRATEGY_ENTRY_REASON_OPTIONS STATS_SEGMENT_DEFS = ( @@ -1032,6 +1052,7 @@ STATS_SEGMENT_DEFS = ( ("key_fib618", "关键位斐波0.618", {"segment": "key_fib618"}), ("key_fib786", "关键位斐波0.786", {"segment": "key_fib786"}), ("key_false_breakout", "关键位假突破", {"segment": "key_false_breakout"}), + ("key_trigger", "关键位触价开仓", {"segment": "key_trigger"}), ) # 复盘表单「其他」选项的 value(非入库值;自定义文本走 entry_reason_custom) ENTRY_REASON_OTHER = "__OTHER__" @@ -1433,6 +1454,7 @@ def init_db(): "ALTER TABLE key_monitors ADD COLUMN manual_take_profit REAL", "ALTER TABLE key_monitors ADD COLUMN breakeven_enabled INTEGER DEFAULT 0", "ALTER TABLE key_monitors ADD COLUMN last_rs_bar_ts INTEGER", + "ALTER TABLE key_monitors ADD COLUMN session_date TEXT", ): try: c.execute(ddl) @@ -1630,6 +1652,8 @@ def _pnl_row_matches_segment(row, segment_key): return kst == "斐波回调0.786" if segment_key == "key_false_breakout": return kst == FALSE_BREAKOUT_MONITOR_TYPE + if segment_key == "key_trigger": + return kst == TRIGGER_ENTRY_MONITOR_TYPE return False @@ -1647,6 +1671,7 @@ def _count_opens_for_segment(conn, start_td, end_td, segment_key): "key_fib618": "斐波回调0.618", "key_fib786": "斐波回调0.786", "key_false_breakout": FALSE_BREAKOUT_MONITOR_TYPE, + "key_trigger": TRIGGER_ENTRY_MONITOR_TYPE, } kst = kst_map.get(segment_key) if kst: @@ -5001,7 +5026,464 @@ def _finalize_fib_key_fill(conn, row): _finalize_key_monitor_one_shot(conn, row, succ, close_reason) -def check_fib_key_monitors(): +def _trigger_entry_exists_for_symbol(conn, symbol): + row = conn.execute( + "SELECT id FROM key_monitors WHERE symbol=? AND monitor_type=?", + (symbol, TRIGGER_ENTRY_MONITOR_TYPE), + ).fetchone() + return row is not None + + +def _add_trigger_entry_key_monitor( + conn, + symbol, + direction_sel, + entry, + sl, + tp, + breakeven_enabled=0, + time_close_enabled=0, + time_close_hours=None, +): + if _trigger_entry_exists_for_symbol(conn, symbol): + return False, f"{symbol} 已有触价开仓监控(同币仅允许一条)" + ex_sym = normalize_exchange_symbol(symbol) + mark = get_symbol_mark_price(symbol) + geom_err = validate_trigger_entry_geometry(direction_sel, entry, sl, tp, mark_at_add=mark) + if geom_err: + return False, geom_err + rr_err = validate_trigger_entry_rr( + direction_sel, entry, sl, tp, KEY_AUTO_MIN_PLANNED_RR, calc_rr_ratio + ) + if rr_err: + return False, rr_err + entry = float(round_price_to_exchange(ex_sym, entry) or entry) + sl = float(round_price_to_exchange(ex_sym, sl) or sl) + tp = float(round_price_to_exchange(ex_sym, tp) or tp) + geom_err = validate_trigger_entry_geometry(direction_sel, entry, sl, tp, mark_at_add=mark) + if geom_err: + return False, geom_err + rr_err = validate_trigger_entry_rr( + direction_sel, entry, sl, tp, KEY_AUTO_MIN_PLANNED_RR, calc_rr_ratio + ) + if rr_err: + return False, rr_err + ok_live, reason_live = ensure_exchange_live_ready() + if not ok_live: + return False, reason_live + now = app_now() + trading_day = get_trading_day(now) + opens_today = count_opens_for_trading_day(conn, trading_day) + ok_intent, intent_msg = check_trigger_entry_intent_limit( + conn, trading_day, opens_today, DAILY_OPEN_HARD_LIMIT + ) + if not ok_intent: + return False, intent_msg + if is_full_margin_mode(POSITION_SIZING_MODE): + ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn)) + if not ok_flat: + return False, flat_msg + if count_pending_trigger_entries(conn, trading_day) > 0: + return False, "全仓杠杆模式下仅允许一条待触发触价监控" + 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) + available_usdt = get_available_trading_usdt() + if is_full_margin_mode(POSITION_SIZING_MODE): + leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE) + sizing, sizing_err = compute_full_margin_sizing( + symbol=symbol, + available_usdt=available_usdt if available_usdt is not None else 0.0, + capital_base=capital_base, + buffer_ratio=FULL_MARGIN_BUFFER_RATIO, + btc_leverage=BTC_LEVERAGE, + alt_leverage=ALT_LEVERAGE, + funds_decimals=2, + ) + if sizing_err: + return False, sizing_err + margin_capital = float(sizing["margin_capital"]) + amount_plan = None + else: + default_leverage = get_synced_leverage(ex_sym, direction_sel) or infer_leverage(symbol) + leverage = int(default_leverage) if default_leverage else 5 + if leverage <= 0: + leverage = 5 + risk_fraction = calc_risk_fraction(direction_sel, entry, sl) + if risk_fraction is None: + return False, "止损方向不合法(相对计划入场价)" + 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, "以损定仓后保证金超过当前交易资金" + 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", + ) + try: + amount_plan, _ = prepare_order_amount(ex_sym, margin_capital, leverage, entry) + except Exception as e: + return False, friendly_exchange_error(e, available_usdt=available_usdt) + upper_px = round_price_to_exchange(ex_sym, max(entry, tp)) + lower_px = round_price_to_exchange(ex_sym, min(entry, sl)) + if upper_px is None or lower_px is None or float(upper_px) <= float(lower_px): + upper_px, lower_px = float(max(entry, tp, sl)), float(min(entry, tp, sl)) + if upper_px <= lower_px: + lower_px = upper_px * 0.9999 + be_flag = 1 if int(breakeven_enabled or 0) != 0 else 0 + tc_en, tc_h, _ = time_close_insert_values(time_close_enabled, time_close_hours, None) + conn.execute( + "INSERT INTO key_monitors " + "(symbol, monitor_type, direction, upper, lower, " + "fib_entry_price, fib_stop_loss, fib_take_profit, " + "fib_order_amount, fib_margin_capital, fib_leverage, breakeven_enabled, " + "time_close_enabled, time_close_hours, session_date) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + TRIGGER_ENTRY_MONITOR_TYPE, + direction_sel, + float(upper_px), + float(lower_px), + entry, + sl, + tp, + float(amount_plan) if amount_plan is not None else None, + margin_capital, + leverage, + be_flag, + tc_en, + tc_h, + trading_day, + ), + ) + return True, None + + +def _market_open_for_trigger_entry( + conn, + symbol, + direction, + exchange_symbol, + entry_price, + stop_loss, + take_profit, + breakeven_enabled=0, + time_close_enabled=0, + time_close_hours=None, +): + """触价触发后市价开仓,计仓规则与实盘下单/关键位 RR 门槛一致。""" + ok_src, src_msg = assert_open_source_allowed(POSITION_SIZING_MODE, OPEN_SOURCE_KEY_TRIGGER) + if not ok_src: + return False, src_msg, None + 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 + + trading_day = get_trading_day(now) + opens_today_before = count_opens_for_trading_day(conn, trading_day) + 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_symbol_mark_price(symbol) or 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 = float(lp_r) + + entry_price = float(entry_price) + 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) + + planned_rr = calc_rr_ratio(direction, entry_price, stop_loss, take_profit) + if planned_rr is None or planned_rr <= KEY_AUTO_MIN_PLANNED_RR: + rr_txt = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算" + return False, f"计划盈亏比 {rr_txt}:1 未达要求(>{KEY_AUTO_MIN_PLANNED_RR}:1)", None + + risk_percent = max(0.01, float(RISK_PERCENT)) + if is_full_margin_mode(POSITION_SIZING_MODE): + ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn)) + if not ok_flat: + return False, flat_msg, None + leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE) + sizing, sizing_err = compute_full_margin_sizing( + symbol=symbol, + available_usdt=available_usdt if available_usdt is not None else 0.0, + capital_base=capital_base, + buffer_ratio=FULL_MARGIN_BUFFER_RATIO, + btc_leverage=BTC_LEVERAGE, + alt_leverage=ALT_LEVERAGE, + funds_decimals=2, + ) + if sizing_err: + return False, sizing_err, None + margin_capital = float(sizing["margin_capital"]) + notional_value = float(sizing["notional_value"]) + position_ratio = float(sizing["position_ratio"]) + risk_amount = margin_capital + else: + 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 + risk_fraction = calc_risk_fraction(direction, entry_price, stop_loss) + if risk_fraction is None: + return False, "止损方向不合法(相对计划入场价)", None + 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_exchange_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_fill = 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 + tc_en, tc_h, tc_at = time_close_insert_values(time_close_enabled, time_close_hours, opened_at_ms) + risk_percent_db = risk_percent_for_storage(POSITION_SIZING_MODE, risk_percent) + + 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, " + "time_close_enabled, time_close_hours, time_close_at_ms) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + exchange_symbol, + direction, + trigger_price, + stop_loss, + stop_loss, + take_profit, + margin_capital, + leverage, + trade_style, + risk_percent_db, + 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(TRIGGER_ENTRY_MONITOR_TYPE), + tc_en, + tc_h, + tc_at, + ), + ) + new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + try_persist_exchange_margin_for_order(conn, new_order_id, exchange_symbol, direction, order_leverage=leverage) + opens_today_after = count_opens_for_trading_day(conn, trading_day) + + return True, None, { + "new_order_id": new_order_id, + "open_order_id": open_order_id, + "trigger_price": trigger_price, + "planned_rr_fill": planned_rr_fill, + "risk_amount_final": risk_amount_final, + "margin_capital": margin_capital, + "leverage": leverage, + "amount": amount, + "tpsl_attached": tpsl_attached, + "opens_today_before": opens_today_before, + "opens_today_after": opens_today_after, + "trading_day": trading_day, + "stop_loss": stop_loss, + "take_profit": take_profit, + } + + +def _execute_trigger_entry_cross(conn, row): + """标记价触达计划入场:先删监控行防重复触发,再市价开仓。""" + symbol = row["symbol"] + direction = (row["direction"] or "long").lower() + ex_sym = normalize_exchange_symbol(symbol) + entry = float(_sqlite_row_val(row, "fib_entry_price") or 0) + sl = float(_sqlite_row_val(row, "fib_stop_loss") or 0) + tp = float(_sqlite_row_val(row, "fib_take_profit") or 0) + be_en = breakeven_enabled_from_row(row, 0) + tc_en, tc_h, _ = time_close_settings_from_row(row) + + kid = int(row["id"]) + conn.execute("DELETE FROM key_monitors WHERE id=?", (kid,)) + conn.commit() + + ok, err, det = _market_open_for_trigger_entry( + conn, + symbol, + direction, + ex_sym, + entry, + sl, + tp, + breakeven_enabled=be_en, + time_close_enabled=tc_en, + time_close_hours=tc_h, + ) + if ok and det: + rr_txt = format_wechat_scalar_2dp(det.get("planned_rr_fill")) if det.get("planned_rr_fill") is not None else "-" + msg = ( + f"# ✅ {symbol} 触价开仓成交\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 来源:{ORDER_MONITOR_TYPE_KEY_AUTO}(程序触价 @ E)\n" + f"- 类型:{TRIGGER_ENTRY_MONITOR_TYPE}|{_wechat_direction_text(direction)}\n" + f"- 订单 ID:**{det.get('new_order_id')}**\n" + f"- 计划入场:{format_price_for_symbol(symbol, entry)}\n" + f"- 成交价:{format_price_for_symbol(symbol, det.get('trigger_price'))}\n" + f"- 止损:{format_wechat_scalar_2dp(det.get('stop_loss'))}|止盈:{format_price_for_symbol(symbol, det.get('take_profit'))}\n" + f"- 计划 RR:{rr_txt}:1\n" + f"- {'已挂交易所 TP/SL' if det.get('tpsl_attached') else 'TP/SL 未挂上'}\n" + ) + send_wechat_msg(msg) + insert_key_monitor_history(conn, row, 0, msg, TRIGGER_ENTRY_CLOSE_FILLED) + return True, None + fail_msg = err or "触价触发后开仓失败" + send_wechat_msg( + f"# ❌ {symbol} 触价开仓失败\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 计划入场:{format_price_for_symbol(symbol, entry)}\n" + f"- 原因:{fail_msg}\n" + ) + insert_key_monitor_history(conn, row, 0, fail_msg, TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED) + return False, fail_msg + + +def check_trigger_entry_key_monitors(): + conn = get_db() + rows = conn.execute("SELECT * FROM key_monitors WHERE monitor_type=?", (TRIGGER_ENTRY_MONITOR_TYPE,)).fetchall() + now_dt = app_now() + for r in rows: + symbol = r["symbol"] + direction = (r["direction"] or "long").lower() + entry = float(_sqlite_row_val(r, "fib_entry_price") or 0) + sl = float(_sqlite_row_val(r, "fib_stop_loss") or 0) + tp = float(_sqlite_row_val(r, "fib_take_profit") or 0) + if entry <= 0 or sl <= 0 or tp <= 0: + _finalize_key_monitor_one_shot(conn, r, "触价计划价位无效", "fib_plan_invalid") + continue + mark = get_symbol_mark_price(symbol) + if mark is None: + continue + if is_trigger_entry_expired(r["created_at"], now_dt, hours=TRIGGER_ENTRY_VALIDITY_HOURS): + exp_txt = trigger_entry_expires_at_text(r["created_at"], hours=TRIGGER_ENTRY_VALIDITY_HOURS) + msg = ( + f"# ⚠️ {symbol} 触价开仓已过期\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{TRIGGER_ENTRY_MONITOR_TYPE}|{_wechat_direction_text(direction)}\n" + f"- 有效期 {TRIGGER_ENTRY_VALIDITY_HOURS}h(应于 {exp_txt} 前触发)\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, TRIGGER_ENTRY_CLOSE_EXPIRED) + continue + if trigger_entry_invalidate_by_tp(direction, mark, tp): + msg = ( + f"# ⚠️ {symbol} 触价开仓失效\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 标记价 {format_price_for_symbol(symbol, mark)} 已触达止盈侧(未成交)\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, TRIGGER_ENTRY_CLOSE_TP_INVALIDATE) + continue + if trigger_entry_reached(direction, mark, entry): + try: + _execute_trigger_entry_cross(conn, r) + except Exception as e: + fail_msg = friendly_exchange_error(e) + try: + insert_key_monitor_history(conn, r, 0, fail_msg, TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED) + except Exception: + pass + send_wechat_msg( + f"# ❌ {symbol} 触价开仓异常\n**账户:{_wechat_account_label()}**\n- {fail_msg}\n" + ) + conn.commit() + conn.close() + + + conn = get_db() rows = conn.execute("SELECT * FROM key_monitors").fetchall() for r in rows: @@ -5879,6 +6361,7 @@ def background_task(): conn.close() force_close_before_reset() check_fib_key_monitors() + check_trigger_entry_key_monitors() _roll_cfg = app.extensions.get("strategy_roll_cfg") if _roll_cfg: from strategy_roll_monitor_lib import check_roll_monitors @@ -6276,6 +6759,7 @@ def render_main_page(page="trade"): key_stop_outside_breakout_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT, key_trend_stop_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT, false_breakout_validity_hours=FALSE_BREAKOUT_VALIDITY_HOURS, + trigger_entry_validity_hours=TRIGGER_ENTRY_VALIDITY_HOURS, ) strategy_extra = {} if page in ("strategy", "strategy_trend", "strategy_roll", "strategy_records"): @@ -6451,7 +6935,7 @@ def api_account_snapshot(): def api_price_snapshot(): conn = get_db() key_rows = conn.execute( - "SELECT id,symbol,monitor_type,direction,upper,lower,fib_entry_price,fib_limit_order_id,created_at FROM key_monitors" + "SELECT id,symbol,monitor_type,direction,upper,lower,fib_entry_price,fib_stop_loss,fib_take_profit,fib_limit_order_id,created_at FROM key_monitors" ).fetchall() order_rows = conn.execute( "SELECT id,symbol,exchange_symbol,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage," @@ -6491,7 +6975,8 @@ def api_price_snapshot(): for r in key_rows: is_fib = is_fib_key_monitor_type(r["monitor_type"]) is_fb = is_false_breakout_key_monitor_type(r["monitor_type"]) - if is_fib or is_fb: + is_te = is_trigger_entry_key_monitor_type(r["monitor_type"]) + if is_fib or is_fb or is_te: price = get_symbol_mark_price(r["symbol"]) else: price = prices.get(r["symbol"]) @@ -6525,6 +7010,24 @@ def api_price_snapshot(): gate_summary = prev.get("summary") or "-" gate_metrics = prev.get("metrics") or "" fb_gate_ok = bool(prev.get("gate_ok")) + elif is_te: + direction = (r["direction"] or "long").lower() + entry = _sqlite_row_val(r, "fib_entry_price") + tp_v = _sqlite_row_val(r, "fib_take_profit") + entry_txt = format_price_for_symbol(r["symbol"], entry) if entry else "-" + tp_txt = format_price_for_symbol(r["symbol"], tp_v) if tp_v else "-" + tp_inv = trigger_entry_invalidate_by_tp(direction, price, float(tp_v)) if tp_v else False + prev = trigger_entry_gate_preview( + entry_display=entry_txt, + take_profit_display=tp_txt, + created_at=_sqlite_row_val(r, "created_at"), + now=app_now(), + tp_invalidated=tp_inv, + hours=TRIGGER_ENTRY_VALIDITY_HOURS, + ) + gate_summary = prev.get("summary") or "-" + gate_metrics = prev.get("metrics") or "" + fib_gate_ok = bool(prev.get("gate_ok")) elif (r["monitor_type"] or "").strip() in KEY_MONITOR_RS_TYPES: try: prev = _key_rs_gate_preview(r["symbol"], r["upper"], r["lower"]) @@ -7123,14 +7626,15 @@ def add_key(): + tuple(KEY_MONITOR_ALERT_ONLY_TYPES) + tuple(FIB_KEY_MONITOR_TYPES) + (FALSE_BREAKOUT_MONITOR_TYPE,) + + (TRIGGER_ENTRY_MONITOR_TYPE,) ) if mt not in allowed_types: flash("监控类型无效") return redirect("/key_monitor") if is_full_margin_mode(POSITION_SIZING_MODE) and monitor_type_disallowed_in_full_margin(mt): flash( - "全仓杠杆模式下不可添加箱体/收敛突破、斐波或假突破监控;" - "请改用阻力/支撑(仅提醒),或切换 POSITION_SIZING_MODE=risk 并重启(须无持仓)。" + "全仓杠杆模式下不可添加箱体/收敛突破、斐波或假突破监控;" + "可使用「触价开仓」或阻力/支撑(仅提醒),或切换 POSITION_SIZING_MODE=risk 并重启(须无持仓)。" ) return redirect("/key_monitor") skip_volume_rank = is_false_breakout_key_monitor_type(mt) @@ -7166,6 +7670,41 @@ def add_key(): tc_h = parse_time_close_hours_form(d.get("time_close_hours")) if tc_en else None if tc_en and not tc_h: tc_en = 0 + if is_trigger_entry_key_monitor_type(mt): + if direction_sel not in ("long", "short"): + conn.close() + conn = None + flash("触价开仓请选择做多或做空") + return redirect("/key_monitor") + try: + entry_px = float(d.get("trigger_entry") or 0) + sl_px = float(d.get("trigger_sl") or 0) + tp_px = float(d.get("trigger_tp") or 0) + except (TypeError, ValueError): + entry_px = sl_px = tp_px = 0 + if entry_px <= 0 or sl_px <= 0 or tp_px <= 0: + conn.close() + conn = None + flash("触价开仓须填写有效的入场价、止损价、止盈价") + return redirect("/key_monitor") + ok_te, err_te = _add_trigger_entry_key_monitor( + conn, symbol, direction_sel, entry_px, sl_px, tp_px, breakeven_enabled=be_flag, + time_close_enabled=tc_en, time_close_hours=tc_h, + ) + conn.commit() + conn.close() + conn = None + if not ok_te: + flash(err_te or "触价开仓监控添加失败") + return redirect("/key_monitor") + flash( + f"触价开仓已添加({symbol} 日成交量排名 {rank}/{total})" + f"|有效期 {TRIGGER_ENTRY_VALIDITY_HOURS}h" + f"|标记价触达入场价后下一轮询市价开仓" + f"|移动保本:{'开' if be_flag else '关'}" + + (f"|{time_close_label(tc_h)}" if tc_en else "") + ) + return redirect("/key_monitor") if is_false_breakout_key_monitor_type(mt): fb_sym = normalize_false_breakout_symbol(symbol) if not fb_sym: diff --git a/crypto_monitor_gate_bot/templates/index.html b/crypto_monitor_gate_bot/templates/index.html index 6407b29..95af7a1 100644 --- a/crypto_monitor_gate_bot/templates/index.html +++ b/crypto_monitor_gate_bot/templates/index.html @@ -1520,14 +1520,19 @@ function syncKeyMonitorFormFields(){ const autoTypes = new Set(["箱体突破","收敛突破"]); const fibTypes = new Set(["斐波回调0.618","斐波回调0.786"]); const fbTypes = new Set(["假突破"]); + const teTypes = new Set(["触价开仓"]); const rsTypes = new Set(["关键阻力位","关键支撑位"]); const showAuto = autoTypes.has(t); const showFb = fbTypes.has(t); - const showBe = showAuto || fibTypes.has(t) || showFb; + const showTe = teTypes.has(t); + const showBe = showAuto || fibTypes.has(t) || showFb || showTe; const showDir = !rsTypes.has(t); const upperEl = document.getElementById("key-upper"); const lowerEl = document.getElementById("key-lower"); const fbPriceEl = document.getElementById("key-fb-price"); + const teEntryEl = document.getElementById("key-trigger-entry"); + const teSlEl = document.getElementById("key-trigger-sl"); + const teTpEl = document.getElementById("key-trigger-tp"); if(dirEl){ dirEl.style.display = showDir ? "" : "none"; dirEl.required = showDir; @@ -1541,15 +1546,16 @@ function syncKeyMonitorFormFields(){ } if(beWrap) beWrap.style.display = showBe ? "inline-flex" : "none"; if(window.TimeCloseUI) TimeCloseUI.syncKeyTimeCloseVisibility(showBe); + const hideBounds = showFb || showTe; if(upperEl){ - upperEl.style.display = showFb ? "none" : ""; - upperEl.required = !showFb; - if(showFb) upperEl.value = ""; + upperEl.style.display = hideBounds ? "none" : ""; + upperEl.required = !hideBounds; + if(hideBounds) upperEl.value = ""; } if(lowerEl){ - lowerEl.style.display = showFb ? "none" : ""; - lowerEl.required = !showFb; - if(showFb) lowerEl.value = ""; + lowerEl.style.display = hideBounds ? "none" : ""; + lowerEl.required = !hideBounds; + if(hideBounds) lowerEl.value = ""; } if(fbPriceEl){ fbPriceEl.style.display = showFb ? "" : "none"; @@ -1557,6 +1563,12 @@ function syncKeyMonitorFormFields(){ if(!showFb) fbPriceEl.value = ""; fbPriceEl.placeholder = (dirEl && dirEl.value === "short") ? "高点(阻力)" : ((dirEl && dirEl.value === "long") ? "低点(支撑)" : "做空填高点/做多填低点"); } + [teEntryEl, teSlEl, teTpEl].forEach((el)=>{ + if(!el) return; + el.style.display = showTe ? "" : "none"; + el.required = showTe; + if(!showTe) el.value = ""; + }); } const keyTypeSel = document.querySelector('#key-form [name="type"]'); const keyModeSel = document.getElementById("key-sl-tp-mode"); diff --git a/crypto_monitor_gate_bot/使用说明.md b/crypto_monitor_gate_bot/使用说明.md index 7153139..714dbd3 100644 --- a/crypto_monitor_gate_bot/使用说明.md +++ b/crypto_monitor_gate_bot/使用说明.md @@ -65,9 +65,10 @@ | **收敛突破** | 同上(自动开仓类)。 | | **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**。 | | **关键支撑位** | 同上(仅提醒)。 | + | **触价开仓** | **不挂交易所限价**;标记价触达计划入场价后 **下一轮询市价开仓**(RR 门槛同关键位 `KEY_AUTO_MIN_PLANNED_RR`);有效期 **24h**;全仓杠杆模式可用。 | -3. **方向**:做多 / 做空(必选)。 -4. **上沿 / 下沿**:必填;保存时会按交易所 **价格精度** 取整。 +3. **方向**:做多 / 做空(触价开仓 / 箱体 / 收敛 / 斐波必选;阻力/支撑不选)。 +4. **价位**:箱体/收敛/阻力/支撑填 **上沿 / 下沿**;触价开仓填 **入场 E / 止损 SL / 止盈 TP**。 **限制:** 活跃持仓数达到 **`MAX_ACTIVE_POSITIONS`**(默认 1)时,**不允许**再添加「**箱体突破** / **收敛突破**」;仍可添加「**关键阻力位 / 支撑位**」。 diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index aaa83aa..e24cd48 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -116,6 +116,24 @@ from manual_sltp_lib import ( resolve_entrust_sltp_prices, resolve_open_sltp_prices, ) +from trigger_entry_key_monitor_lib import ( + TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED, + TRIGGER_ENTRY_CLOSE_EXPIRED, + TRIGGER_ENTRY_CLOSE_FILLED, + TRIGGER_ENTRY_CLOSE_TP_INVALIDATE, + TRIGGER_ENTRY_MONITOR_TYPE, + TRIGGER_ENTRY_VALIDITY_HOURS, + check_trigger_entry_intent_limit, + count_pending_trigger_entries, + is_trigger_entry_expired, + is_trigger_entry_key_monitor_type, + trigger_entry_expires_at_text, + trigger_entry_gate_preview, + trigger_entry_invalidate_by_tp, + trigger_entry_reached, + validate_trigger_entry_geometry, + validate_trigger_entry_rr, +) from position_sizing_lib import ( OPEN_SOURCE_KEY_AUTO, OPEN_SOURCE_MANUAL, @@ -1029,6 +1047,7 @@ ENTRY_REASON_OPTIONS = ( "关键位斐波0.618", "关键位斐波0.786", "关键位假突破", + "关键位触价开仓", ) + STRATEGY_ENTRY_REASON_OPTIONS STATS_SEGMENT_DEFS = ( @@ -1039,6 +1058,7 @@ STATS_SEGMENT_DEFS = ( ("key_fib618", "关键位斐波0.618", {"segment": "key_fib618"}), ("key_fib786", "关键位斐波0.786", {"segment": "key_fib786"}), ("key_false_breakout", "关键位假突破", {"segment": "key_false_breakout"}), + ("key_trigger", "关键位触价开仓", {"segment": "key_trigger"}), ) ENTRY_REASON_OTHER = "__OTHER__" @@ -1434,6 +1454,7 @@ def init_db(): "ALTER TABLE key_monitors ADD COLUMN manual_take_profit REAL", "ALTER TABLE key_monitors ADD COLUMN breakeven_enabled INTEGER DEFAULT 0", "ALTER TABLE key_monitors ADD COLUMN last_rs_bar_ts INTEGER", + "ALTER TABLE key_monitors ADD COLUMN session_date TEXT", ): try: c.execute(ddl) @@ -1613,6 +1634,8 @@ def _pnl_row_matches_segment(row, segment_key): return kst == "斐波回调0.786" if segment_key == "key_false_breakout": return kst == FALSE_BREAKOUT_MONITOR_TYPE + if segment_key == "key_trigger": + return kst == TRIGGER_ENTRY_MONITOR_TYPE return False @@ -4560,7 +4583,464 @@ def _finalize_fib_key_fill(conn, row): _finalize_key_monitor_one_shot(conn, row, succ, close_reason) -def check_fib_key_monitors(): +def _trigger_entry_exists_for_symbol(conn, symbol): + row = conn.execute( + "SELECT id FROM key_monitors WHERE symbol=? AND monitor_type=?", + (symbol, TRIGGER_ENTRY_MONITOR_TYPE), + ).fetchone() + return row is not None + + +def _add_trigger_entry_key_monitor( + conn, + symbol, + direction_sel, + entry, + sl, + tp, + breakeven_enabled=0, + time_close_enabled=0, + time_close_hours=None, +): + if _trigger_entry_exists_for_symbol(conn, symbol): + return False, f"{symbol} 已有触价开仓监控(同币仅允许一条)" + ex_sym = normalize_exchange_symbol(symbol) + mark = get_symbol_mark_price(symbol) + geom_err = validate_trigger_entry_geometry(direction_sel, entry, sl, tp, mark_at_add=mark) + if geom_err: + return False, geom_err + rr_err = validate_trigger_entry_rr( + direction_sel, entry, sl, tp, KEY_AUTO_MIN_PLANNED_RR, calc_rr_ratio + ) + if rr_err: + return False, rr_err + entry = float(round_price_to_exchange(ex_sym, entry) or entry) + sl = float(round_price_to_exchange(ex_sym, sl) or sl) + tp = float(round_price_to_exchange(ex_sym, tp) or tp) + geom_err = validate_trigger_entry_geometry(direction_sel, entry, sl, tp, mark_at_add=mark) + if geom_err: + return False, geom_err + rr_err = validate_trigger_entry_rr( + direction_sel, entry, sl, tp, KEY_AUTO_MIN_PLANNED_RR, calc_rr_ratio + ) + if rr_err: + return False, rr_err + ok_live, reason_live = ensure_exchange_live_ready() + if not ok_live: + return False, reason_live + now = app_now() + trading_day = get_trading_day(now) + opens_today = count_opens_for_trading_day(conn, trading_day) + ok_intent, intent_msg = check_trigger_entry_intent_limit( + conn, trading_day, opens_today, DAILY_OPEN_HARD_LIMIT + ) + if not ok_intent: + return False, intent_msg + if is_full_margin_mode(POSITION_SIZING_MODE): + ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn)) + if not ok_flat: + return False, flat_msg + if count_pending_trigger_entries(conn, trading_day) > 0: + return False, "全仓杠杆模式下仅允许一条待触发触价监控" + 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) + available_usdt = get_available_trading_usdt() + if is_full_margin_mode(POSITION_SIZING_MODE): + leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE) + sizing, sizing_err = compute_full_margin_sizing( + symbol=symbol, + available_usdt=available_usdt if available_usdt is not None else 0.0, + capital_base=capital_base, + buffer_ratio=FULL_MARGIN_BUFFER_RATIO, + btc_leverage=BTC_LEVERAGE, + alt_leverage=ALT_LEVERAGE, + funds_decimals=2, + ) + if sizing_err: + return False, sizing_err + margin_capital = float(sizing["margin_capital"]) + amount_plan = None + else: + default_leverage = get_synced_leverage(ex_sym, direction_sel) or infer_leverage(symbol) + leverage = int(default_leverage) if default_leverage else 5 + if leverage <= 0: + leverage = 5 + risk_fraction = calc_risk_fraction(direction_sel, entry, sl) + if risk_fraction is None: + return False, "止损方向不合法(相对计划入场价)" + 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, "以损定仓后保证金超过当前交易资金" + 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", + ) + try: + amount_plan, _ = prepare_order_amount(ex_sym, margin_capital, leverage, entry) + except Exception as e: + return False, friendly_exchange_error(e, available_usdt=available_usdt) + upper_px = round_price_to_exchange(ex_sym, max(entry, tp)) + lower_px = round_price_to_exchange(ex_sym, min(entry, sl)) + if upper_px is None or lower_px is None or float(upper_px) <= float(lower_px): + upper_px, lower_px = float(max(entry, tp, sl)), float(min(entry, tp, sl)) + if upper_px <= lower_px: + lower_px = upper_px * 0.9999 + be_flag = 1 if int(breakeven_enabled or 0) != 0 else 0 + tc_en, tc_h, _ = time_close_insert_values(time_close_enabled, time_close_hours, None) + conn.execute( + "INSERT INTO key_monitors " + "(symbol, monitor_type, direction, upper, lower, " + "fib_entry_price, fib_stop_loss, fib_take_profit, " + "fib_order_amount, fib_margin_capital, fib_leverage, breakeven_enabled, " + "time_close_enabled, time_close_hours, session_date) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + TRIGGER_ENTRY_MONITOR_TYPE, + direction_sel, + float(upper_px), + float(lower_px), + entry, + sl, + tp, + float(amount_plan) if amount_plan is not None else None, + margin_capital, + leverage, + be_flag, + tc_en, + tc_h, + trading_day, + ), + ) + return True, None + + +def _market_open_for_trigger_entry( + conn, + symbol, + direction, + exchange_symbol, + entry_price, + stop_loss, + take_profit, + breakeven_enabled=0, + time_close_enabled=0, + time_close_hours=None, +): + """触价触发后市价开仓,计仓规则与实盘下单/关键位 RR 门槛一致。""" + ok_src, src_msg = assert_open_source_allowed(POSITION_SIZING_MODE, OPEN_SOURCE_KEY_TRIGGER) + if not ok_src: + return False, src_msg, None + 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 + + trading_day = get_trading_day(now) + opens_today_before = count_opens_for_trading_day(conn, trading_day) + 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_symbol_mark_price(symbol) or 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 = float(lp_r) + + entry_price = float(entry_price) + 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) + + planned_rr = calc_rr_ratio(direction, entry_price, stop_loss, take_profit) + if planned_rr is None or planned_rr <= KEY_AUTO_MIN_PLANNED_RR: + rr_txt = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算" + return False, f"计划盈亏比 {rr_txt}:1 未达要求(>{KEY_AUTO_MIN_PLANNED_RR}:1)", None + + risk_percent = max(0.01, float(RISK_PERCENT)) + if is_full_margin_mode(POSITION_SIZING_MODE): + ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn)) + if not ok_flat: + return False, flat_msg, None + leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE) + sizing, sizing_err = compute_full_margin_sizing( + symbol=symbol, + available_usdt=available_usdt if available_usdt is not None else 0.0, + capital_base=capital_base, + buffer_ratio=FULL_MARGIN_BUFFER_RATIO, + btc_leverage=BTC_LEVERAGE, + alt_leverage=ALT_LEVERAGE, + funds_decimals=2, + ) + if sizing_err: + return False, sizing_err, None + margin_capital = float(sizing["margin_capital"]) + notional_value = float(sizing["notional_value"]) + position_ratio = float(sizing["position_ratio"]) + risk_amount = margin_capital + else: + 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 + risk_fraction = calc_risk_fraction(direction, entry_price, stop_loss) + if risk_fraction is None: + return False, "止损方向不合法(相对计划入场价)", None + 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_exchange_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_fill = 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 + tc_en, tc_h, tc_at = time_close_insert_values(time_close_enabled, time_close_hours, opened_at_ms) + risk_percent_db = risk_percent_for_storage(POSITION_SIZING_MODE, risk_percent) + + 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, " + "time_close_enabled, time_close_hours, time_close_at_ms) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + exchange_symbol, + direction, + trigger_price, + stop_loss, + stop_loss, + take_profit, + margin_capital, + leverage, + trade_style, + risk_percent_db, + 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(TRIGGER_ENTRY_MONITOR_TYPE), + tc_en, + tc_h, + tc_at, + ), + ) + new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + try_persist_exchange_margin_for_order(conn, new_order_id, exchange_symbol, direction, order_leverage=leverage) + opens_today_after = count_opens_for_trading_day(conn, trading_day) + + return True, None, { + "new_order_id": new_order_id, + "open_order_id": open_order_id, + "trigger_price": trigger_price, + "planned_rr_fill": planned_rr_fill, + "risk_amount_final": risk_amount_final, + "margin_capital": margin_capital, + "leverage": leverage, + "amount": amount, + "tpsl_attached": tpsl_attached, + "opens_today_before": opens_today_before, + "opens_today_after": opens_today_after, + "trading_day": trading_day, + "stop_loss": stop_loss, + "take_profit": take_profit, + } + + +def _execute_trigger_entry_cross(conn, row): + """标记价触达计划入场:先删监控行防重复触发,再市价开仓。""" + symbol = row["symbol"] + direction = (row["direction"] or "long").lower() + ex_sym = normalize_exchange_symbol(symbol) + entry = float(_sqlite_row_val(row, "fib_entry_price") or 0) + sl = float(_sqlite_row_val(row, "fib_stop_loss") or 0) + tp = float(_sqlite_row_val(row, "fib_take_profit") or 0) + be_en = breakeven_enabled_from_row(row, 0) + tc_en, tc_h, _ = time_close_settings_from_row(row) + + kid = int(row["id"]) + conn.execute("DELETE FROM key_monitors WHERE id=?", (kid,)) + conn.commit() + + ok, err, det = _market_open_for_trigger_entry( + conn, + symbol, + direction, + ex_sym, + entry, + sl, + tp, + breakeven_enabled=be_en, + time_close_enabled=tc_en, + time_close_hours=tc_h, + ) + if ok and det: + rr_txt = format_wechat_scalar_2dp(det.get("planned_rr_fill")) if det.get("planned_rr_fill") is not None else "-" + msg = ( + f"# ✅ {symbol} 触价开仓成交\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 来源:{ORDER_MONITOR_TYPE_KEY_AUTO}(程序触价 @ E)\n" + f"- 类型:{TRIGGER_ENTRY_MONITOR_TYPE}|{_wechat_direction_text(direction)}\n" + f"- 订单 ID:**{det.get('new_order_id')}**\n" + f"- 计划入场:{format_price_for_symbol(symbol, entry)}\n" + f"- 成交价:{format_price_for_symbol(symbol, det.get('trigger_price'))}\n" + f"- 止损:{format_wechat_scalar_2dp(det.get('stop_loss'))}|止盈:{format_price_for_symbol(symbol, det.get('take_profit'))}\n" + f"- 计划 RR:{rr_txt}:1\n" + f"- {'已挂交易所 TP/SL' if det.get('tpsl_attached') else 'TP/SL 未挂上'}\n" + ) + send_wechat_msg(msg) + insert_key_monitor_history(conn, row, 0, msg, TRIGGER_ENTRY_CLOSE_FILLED) + return True, None + fail_msg = err or "触价触发后开仓失败" + send_wechat_msg( + f"# ❌ {symbol} 触价开仓失败\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 计划入场:{format_price_for_symbol(symbol, entry)}\n" + f"- 原因:{fail_msg}\n" + ) + insert_key_monitor_history(conn, row, 0, fail_msg, TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED) + return False, fail_msg + + +def check_trigger_entry_key_monitors(): + conn = get_db() + rows = conn.execute("SELECT * FROM key_monitors WHERE monitor_type=?", (TRIGGER_ENTRY_MONITOR_TYPE,)).fetchall() + now_dt = app_now() + for r in rows: + symbol = r["symbol"] + direction = (r["direction"] or "long").lower() + entry = float(_sqlite_row_val(r, "fib_entry_price") or 0) + sl = float(_sqlite_row_val(r, "fib_stop_loss") or 0) + tp = float(_sqlite_row_val(r, "fib_take_profit") or 0) + if entry <= 0 or sl <= 0 or tp <= 0: + _finalize_key_monitor_one_shot(conn, r, "触价计划价位无效", "fib_plan_invalid") + continue + mark = get_symbol_mark_price(symbol) + if mark is None: + continue + if is_trigger_entry_expired(r["created_at"], now_dt, hours=TRIGGER_ENTRY_VALIDITY_HOURS): + exp_txt = trigger_entry_expires_at_text(r["created_at"], hours=TRIGGER_ENTRY_VALIDITY_HOURS) + msg = ( + f"# ⚠️ {symbol} 触价开仓已过期\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{TRIGGER_ENTRY_MONITOR_TYPE}|{_wechat_direction_text(direction)}\n" + f"- 有效期 {TRIGGER_ENTRY_VALIDITY_HOURS}h(应于 {exp_txt} 前触发)\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, TRIGGER_ENTRY_CLOSE_EXPIRED) + continue + if trigger_entry_invalidate_by_tp(direction, mark, tp): + msg = ( + f"# ⚠️ {symbol} 触价开仓失效\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 标记价 {format_price_for_symbol(symbol, mark)} 已触达止盈侧(未成交)\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, TRIGGER_ENTRY_CLOSE_TP_INVALIDATE) + continue + if trigger_entry_reached(direction, mark, entry): + try: + _execute_trigger_entry_cross(conn, r) + except Exception as e: + fail_msg = friendly_exchange_error(e) + try: + insert_key_monitor_history(conn, r, 0, fail_msg, TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED) + except Exception: + pass + send_wechat_msg( + f"# ❌ {symbol} 触价开仓异常\n**账户:{_wechat_account_label()}**\n- {fail_msg}\n" + ) + conn.commit() + conn.close() + + + conn = get_db() rows = conn.execute("SELECT * FROM key_monitors").fetchall() for r in rows: @@ -5662,6 +6142,7 @@ def background_task(): conn.close() force_close_before_reset() check_fib_key_monitors() + check_trigger_entry_key_monitors() _roll_cfg = app.extensions.get("strategy_roll_cfg") if _roll_cfg: from strategy_roll_monitor_lib import check_roll_monitors @@ -5821,6 +6302,7 @@ def render_main_page(page="trade"): key_stop_outside_breakout_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT, key_trend_stop_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT, false_breakout_validity_hours=FALSE_BREAKOUT_VALIDITY_HOURS, + trigger_entry_validity_hours=TRIGGER_ENTRY_VALIDITY_HOURS, ) strategy_extra = {} if page in ("strategy", "strategy_trend", "strategy_roll", "strategy_records"): @@ -6037,7 +6519,7 @@ def api_settings_open_guard(): def api_price_snapshot(): conn = get_db() key_rows = conn.execute( - "SELECT id,symbol,monitor_type,direction,upper,lower,fib_entry_price,fib_limit_order_id,created_at FROM key_monitors" + "SELECT id,symbol,monitor_type,direction,upper,lower,fib_entry_price,fib_stop_loss,fib_take_profit,fib_limit_order_id,created_at FROM key_monitors" ).fetchall() order_rows = conn.execute( "SELECT id,symbol,exchange_symbol,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage," @@ -6077,7 +6559,8 @@ def api_price_snapshot(): for r in key_rows: is_fib = is_fib_key_monitor_type(r["monitor_type"]) is_fb = is_false_breakout_key_monitor_type(r["monitor_type"]) - if is_fib or is_fb: + is_te = is_trigger_entry_key_monitor_type(r["monitor_type"]) + if is_fib or is_fb or is_te: price = get_symbol_mark_price(r["symbol"]) else: price = prices.get(r["symbol"]) @@ -6111,6 +6594,24 @@ def api_price_snapshot(): gate_summary = prev.get("summary") or "-" gate_metrics = prev.get("metrics") or "" fb_gate_ok = bool(prev.get("gate_ok")) + elif is_te: + direction = (r["direction"] or "long").lower() + entry = _sqlite_row_val(r, "fib_entry_price") + tp_v = _sqlite_row_val(r, "fib_take_profit") + entry_txt = format_price_for_symbol(r["symbol"], entry) if entry else "-" + tp_txt = format_price_for_symbol(r["symbol"], tp_v) if tp_v else "-" + tp_inv = trigger_entry_invalidate_by_tp(direction, price, float(tp_v)) if tp_v else False + prev = trigger_entry_gate_preview( + entry_display=entry_txt, + take_profit_display=tp_txt, + created_at=_sqlite_row_val(r, "created_at"), + now=app_now(), + tp_invalidated=tp_inv, + hours=TRIGGER_ENTRY_VALIDITY_HOURS, + ) + gate_summary = prev.get("summary") or "-" + gate_metrics = prev.get("metrics") or "" + fib_gate_ok = bool(prev.get("gate_ok")) elif (r["monitor_type"] or "").strip() in KEY_MONITOR_RS_TYPES: try: prev = _key_rs_gate_preview(r["symbol"], r["upper"], r["lower"]) @@ -6714,6 +7215,7 @@ def add_key(): + tuple(KEY_MONITOR_ALERT_ONLY_TYPES) + tuple(FIB_KEY_MONITOR_TYPES) + (FALSE_BREAKOUT_MONITOR_TYPE,) + + (TRIGGER_ENTRY_MONITOR_TYPE,) ) if mt not in allowed_types: flash("监控类型无效") @@ -6721,7 +7223,7 @@ def add_key(): if is_full_margin_mode(POSITION_SIZING_MODE) and monitor_type_disallowed_in_full_margin(mt): flash( "全仓杠杆模式下不可添加箱体/收敛突破、斐波或假突破监控;" - "请改用阻力/支撑(仅提醒),或切换 POSITION_SIZING_MODE=risk 并重启(须无持仓)。" + "可使用「触价开仓」或阻力/支撑(仅提醒),或切换 POSITION_SIZING_MODE=risk 并重启(须无持仓)。" ) return redirect("/key_monitor") skip_volume_rank = is_false_breakout_key_monitor_type(mt) diff --git a/crypto_monitor_okx/templates/index.html b/crypto_monitor_okx/templates/index.html index 7fc200c..9cbf097 100644 --- a/crypto_monitor_okx/templates/index.html +++ b/crypto_monitor_okx/templates/index.html @@ -1549,14 +1549,19 @@ function syncKeyMonitorFormFields(){ const autoTypes = new Set(["箱体突破","收敛突破"]); const fibTypes = new Set(["斐波回调0.618","斐波回调0.786"]); const fbTypes = new Set(["假突破"]); + const teTypes = new Set(["触价开仓"]); const rsTypes = new Set(["关键阻力位","关键支撑位"]); const showAuto = autoTypes.has(t); const showFb = fbTypes.has(t); - const showBe = showAuto || fibTypes.has(t) || showFb; + const showTe = teTypes.has(t); + const showBe = showAuto || fibTypes.has(t) || showFb || showTe; const showDir = !rsTypes.has(t); const upperEl = document.getElementById("key-upper"); const lowerEl = document.getElementById("key-lower"); const fbPriceEl = document.getElementById("key-fb-price"); + const teEntryEl = document.getElementById("key-trigger-entry"); + const teSlEl = document.getElementById("key-trigger-sl"); + const teTpEl = document.getElementById("key-trigger-tp"); if(dirEl){ dirEl.style.display = showDir ? "" : "none"; dirEl.required = showDir; @@ -1570,15 +1575,16 @@ function syncKeyMonitorFormFields(){ } if(beWrap) beWrap.style.display = showBe ? "inline-flex" : "none"; if(window.TimeCloseUI) TimeCloseUI.syncKeyTimeCloseVisibility(showBe); + const hideBounds = showFb || showTe; if(upperEl){ - upperEl.style.display = showFb ? "none" : ""; - upperEl.required = !showFb; - if(showFb) upperEl.value = ""; + upperEl.style.display = hideBounds ? "none" : ""; + upperEl.required = !hideBounds; + if(hideBounds) upperEl.value = ""; } if(lowerEl){ - lowerEl.style.display = showFb ? "none" : ""; - lowerEl.required = !showFb; - if(showFb) lowerEl.value = ""; + lowerEl.style.display = hideBounds ? "none" : ""; + lowerEl.required = !hideBounds; + if(hideBounds) lowerEl.value = ""; } if(fbPriceEl){ fbPriceEl.style.display = showFb ? "" : "none"; @@ -1586,6 +1592,12 @@ function syncKeyMonitorFormFields(){ if(!showFb) fbPriceEl.value = ""; fbPriceEl.placeholder = (dirEl && dirEl.value === "short") ? "高点(阻力)" : ((dirEl && dirEl.value === "long") ? "低点(支撑)" : "做空填高点/做多填低点"); } + [teEntryEl, teSlEl, teTpEl].forEach((el)=>{ + if(!el) return; + el.style.display = showTe ? "" : "none"; + el.required = showTe; + if(!showTe) el.value = ""; + }); } const keyTypeSel = document.querySelector('#key-form [name="type"]'); const keyModeSel = document.getElementById("key-sl-tp-mode"); diff --git a/crypto_monitor_okx/使用说明.md b/crypto_monitor_okx/使用说明.md index 7310c20..9a65d04 100644 --- a/crypto_monitor_okx/使用说明.md +++ b/crypto_monitor_okx/使用说明.md @@ -65,9 +65,10 @@ | **收敛突破** | 同上(自动开仓类)。 | | **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**。 | | **关键支撑位** | 同上(仅提醒)。 | + | **触价开仓** | **不挂交易所限价**;标记价触达计划入场价后 **下一轮询市价开仓**(RR 门槛同关键位 `KEY_AUTO_MIN_PLANNED_RR`);有效期 **24h**;全仓杠杆模式可用。 | -3. **方向**:做多 / 做空(必选)。 -4. **上沿 / 下沿**:必填;保存时会按交易所 **价格精度** 取整。 +3. **方向**:做多 / 做空(触价开仓 / 箱体 / 收敛 / 斐波必选;阻力/支撑不选)。 +4. **价位**:箱体/收敛/阻力/支撑填 **上沿 / 下沿**;触价开仓填 **入场 E / 止损 SL / 止盈 TP**。 **限制:** 活跃持仓数达到 **`MAX_ACTIVE_POSITIONS`**(默认 1)时,**不允许**再添加「**箱体突破** / **收敛突破**」;仍可添加「**关键阻力位 / 支撑位**」。 diff --git a/crypto_monitor_okx/关键位自动下单说明.md b/crypto_monitor_okx/关键位自动下单说明.md index 7218e11..c352af0 100644 --- a/crypto_monitor_okx/关键位自动下单说明.md +++ b/crypto_monitor_okx/关键位自动下单说明.md @@ -1,7 +1,7 @@ # 关键位监控说明(自动开仓 + 人工盯盘) -**适用:`crypto_monitor_okx`(OKX 永续)** -箱体/收敛与 Binance、Gate 相同:**门控通过后自动市价开仓**(须 `LIVE_TRADING_ENABLED=true`)。阻力/支撑仍为微信提醒。共享逻辑见 `key_monitor_lib.py`。 +**适用:`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` 一致。 @@ -16,8 +16,9 @@ | **关键阻力位** | **不选**(`direction=watch`) | **否** | 5m 收盘突破上/下沿 → 微信 **3 次** → `key_level_alert_done` | | **关键支撑位** | **不选** | **否** | 同上(与阻力位**相同规则**:填上沿+下沿,程序双向监控) | | 斐波回调 0.618 / 0.786 | 必选 | 限价挂单逻辑 | 见斐波说明(**不在下文展开**) | +| **触价开仓** | **必选** 多/空 | **程序盯价 → 触 E 后市价** | 见下文 **§六** | -**添加时(所有类型):** 品种须 **日成交量排名前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30)**;上沿 **>** 下沿。 +**添加时(箱体/收敛/斐波/触价):** 品种须 **日成交量排名前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30)**;上沿 **>** 下沿(触价开仓填 E/SL/TP,上下沿仅作展示占位)。 --- @@ -117,7 +118,31 @@ --- -## 四、环境与参数(`.env` 摘要) +## 四、触价开仓(程序触价,无交易所挂单) + +### 4.1 录入 + +- 类型选 **触价开仓**;方向必选多/空。 +- 填写 **计划入场价 E**、**止损 SL**、**止盈 TP**(做多须 `SL < E < TP`)。 +- 计划 RR 以 **E** 为基准,须 **严格大于** `KEY_AUTO_MIN_PLANNED_RR`(默认 1.5,与箱体/斐波相同)。 +- 可选移动保本、时间平仓;**全仓杠杆模式**下可用(页面隐藏箱体/收敛/斐波/假突破)。 + +### 4.2 触发与结案 + +- 轮询标记价:做多 `标记价 ≤ E`、做空 `标记价 ≥ E` → **下一轮询市价开仓**,挂交易所 TP/SL,进下单监控。 +- 未成交前标记价先触 **TP 侧** → `trigger_tp_invalidate`;**24h** 未触发 → `trigger_entry_expired`。 +- 成功 → `trigger_entry_filled`;触发后开仓失败 → `trigger_exchange_failed`。 + +### 4.3 计仓与占位 + +- **以损定仓**:按 E、SL 反推保证金,触发时重算;**全仓杠杆**:可用×缓冲比例,BTC/ETH 10x、其它 5x。 +- **占当日开仓意图**(已开 + 待触发),未成交不占持仓;同币仅 1 条。 + +共享逻辑:`trigger_entry_key_monitor_lib.py`;轮询:`check_trigger_entry_key_monitors`。 + +--- + +## 五、环境与参数(`.env` 摘要) | 变量 | 箱体/收敛 | 阻力/支撑 | |------|-----------|-----------| @@ -130,7 +155,7 @@ --- -## 五、相关代码 +## 六、相关代码 | 说明 | 位置 | |------|------| diff --git a/docs/position-sizing-mode.md b/docs/position-sizing-mode.md index eb2eb5f..bbce436 100644 --- a/docs/position-sizing-mode.md +++ b/docs/position-sizing-mode.md @@ -18,20 +18,22 @@ FULL_MARGIN_BUFFER_RATIO=0.98 | 模式 | 保证金计算 | 杠杆 | 允许入口 | |------|------------|------|----------| | `risk` | `RISK_PERCENT` × 交易资金,按止损距离反推 | 表单可选 / 同步交易所 | 实盘人工、关键位自动、趋势回调、顺势加仓 | -| `full_margin` | **合约账户可用 USDT × `FULL_MARGIN_BUFFER_RATIO`**(保留 2 位小数) | BTC/ETH **10x**,其它 **5x**(与 `BTC_LEVERAGE`/`ALT_LEVERAGE` 一致) | **仅** 实盘人工下单;阻力/支撑仅提醒 | +| `full_margin` | **合约账户可用 USDT × `FULL_MARGIN_BUFFER_RATIO`**(保留 2 位小数) | BTC/ETH **10x**,其它 **5x**(与 `BTC_LEVERAGE`/`ALT_LEVERAGE` 一致) | **实盘人工下单**、**关键位触价开仓**;阻力/支撑仅提醒 | 全仓模式下: -- 仍校验 **计划盈亏比**(`MANUAL_MIN_PLANNED_RR`)。 +- 仍校验 **计划盈亏比**(实盘用 `MANUAL_MIN_PLANNED_RR`;触价开仓用 `KEY_AUTO_MIN_PLANNED_RR`)。 - 下单张数由 `prepare_order_amount` + 交易所 `amount_to_precision` 决定。 - `order_monitors.initial_stop_loss` 仍记录**开仓时**止损快照;交易记录复盘以该快照为准。 -- 已存在的 **箱体突破 / 收敛突破 / 斐波** 监控:进程启动时**自动撤销**并企业微信通知。 +- 已存在的 **箱体突破 / 收敛突破 / 斐波 / 假突破** 监控:进程启动时**自动撤销**并企业微信通知。 ## 不允许(全仓模式) -- 关键位:箱体突破、收敛突破、斐波自动单(添加时拒绝;已存在则启动时撤销)。 +- 关键位:箱体突破、收敛突破、斐波、假突破(添加时拒绝;已存在则启动时撤销)。 - 趋势回调、顺势加仓(策略入口返回明确错误)。 +**允许:** 关键位 **触价开仓**(程序盯价、触达计划入场后市价成交,无交易所挂单;全仓下仅允许一条待触发)。 + ## 用脚本更新四所 `.env` 详见 **[env-sync-scripts.md](./env-sync-scripts.md)**。常用命令: diff --git a/fib_key_monitor_lib.py b/fib_key_monitor_lib.py index 4b30062..29e0162 100644 --- a/fib_key_monitor_lib.py +++ b/fib_key_monitor_lib.py @@ -44,11 +44,11 @@ def calc_fib_plan(direction, upper, lower, ratio): def stored_key_signal_type(monitor_type): - """写入 order_monitors / trade_records 的 key_signal_type(箱体/收敛/斐波/假突破)。""" + """写入 order_monitors / trade_records 的 key_signal_type(箱体/收敛/斐波/假突破/触价开仓)。""" mt = (monitor_type or "").strip() if mt in FIB_KEY_MONITOR_TYPES: return mt - if mt == "假突破": + if mt in ("假突破", "触价开仓"): return mt if mt in KEY_MONITOR_AUTO_TYPES: return mt @@ -61,6 +61,7 @@ KEY_ENTRY_REASON_BY_SIGNAL = { "斐波回调0.618": "关键位斐波0.618", "斐波回调0.786": "关键位斐波0.786", "假突破": "关键位假突破", + "触价开仓": "关键位触价开仓", "趋势回调": "趋势回调", } @@ -76,6 +77,8 @@ def key_signal_type_for_trade_record(key_signal_type, box_auto_types): return kst if kst == "假突破": return kst + if kst == "触价开仓": + return kst if box_auto_types and kst in box_auto_types: return kst return None diff --git a/key_monitor_lib.py b/key_monitor_lib.py index 23f2a5d..3e0e96c 100644 --- a/key_monitor_lib.py +++ b/key_monitor_lib.py @@ -310,6 +310,7 @@ def key_monitor_rule_template_context( key_stop_outside_breakout_pct: float, key_trend_stop_outside_pct: float, false_breakout_validity_hours: int, + trigger_entry_validity_hours: int | None = None, ) -> dict[str, Any]: """关键位监控页规则说明表格(Jinja key_rule_ctx)。""" from false_breakout_key_monitor_lib import ( @@ -317,6 +318,13 @@ def key_monitor_rule_template_context( FALSE_BREAKOUT_RR, FALSE_BREAKOUT_SL_PCT, ) + from trigger_entry_key_monitor_lib import TRIGGER_ENTRY_VALIDITY_HOURS + + te_hours = ( + int(trigger_entry_validity_hours) + if trigger_entry_validity_hours is not None + else TRIGGER_ENTRY_VALIDITY_HOURS + ) return { "tf": (kline_timeframe or "5m").strip(), @@ -335,4 +343,5 @@ def key_monitor_rule_template_context( "fb_sl_pct": FALSE_BREAKOUT_SL_PCT, "fb_rr": FALSE_BREAKOUT_RR, "fb_valid_hours": false_breakout_validity_hours, + "trigger_entry_validity_hours": te_hours, } diff --git a/position_sizing_lib.py b/position_sizing_lib.py index 06b19e0..4715dc9 100644 --- a/position_sizing_lib.py +++ b/position_sizing_lib.py @@ -14,6 +14,7 @@ VALID_MODES = frozenset({MODE_RISK, MODE_FULL_MARGIN}) OPEN_SOURCE_MANUAL = "manual" OPEN_SOURCE_KEY_AUTO = "key_auto" OPEN_SOURCE_KEY_FIB = "key_fib" +OPEN_SOURCE_KEY_TRIGGER = "key_trigger" OPEN_SOURCE_TREND = "trend" OPEN_SOURCE_ROLL = "roll" diff --git a/scripts/patch_trigger_entry_binance_okx.py b/scripts/patch_trigger_entry_binance_okx.py new file mode 100644 index 0000000..1fb548b --- /dev/null +++ b/scripts/patch_trigger_entry_binance_okx.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""补打 binance / okx 触价开仓补丁。""" +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +GATE = ROOT / "crypto_monitor_gate" / "app.py" + +def extract_block(): + text = GATE.read_text(encoding="utf-8") + i = text.index("def _trigger_entry_exists_for_symbol") + j = text.index("def check_fib_key_monitors():") + return text[i:j] + +ADD_BRANCH = GATE.read_text(encoding="utf-8").split( + " if tc_en and not tc_h:\n tc_en = 0\n if is_trigger_entry_key_monitor_type(mt):" +)[1].split(" if is_false_breakout_key_monitor_type(mt):")[0] + +TE_BRANCH = """ elif is_te: + direction = (r["direction"] or "long").lower() + entry = _sqlite_row_val(r, "fib_entry_price") + tp_v = _sqlite_row_val(r, "fib_take_profit") + entry_txt = format_price_for_symbol(r["symbol"], entry) if entry else "-" + tp_txt = format_price_for_symbol(r["symbol"], tp_v) if tp_v else "-" + tp_inv = trigger_entry_invalidate_by_tp(direction, price, float(tp_v)) if tp_v else False + prev = trigger_entry_gate_preview( + entry_display=entry_txt, + take_profit_display=tp_txt, + created_at=_sqlite_row_val(r, "created_at"), + now=app_now(), + tp_invalidated=tp_inv, + hours=TRIGGER_ENTRY_VALIDITY_HOURS, + ) + gate_summary = prev.get("summary") or "-" + gate_metrics = prev.get("metrics") or "" + fib_gate_ok = bool(prev.get("gate_ok")) +""" + +TRIG_IMPORT = """from trigger_entry_key_monitor_lib import ( + TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED, + TRIGGER_ENTRY_CLOSE_EXPIRED, + TRIGGER_ENTRY_CLOSE_FILLED, + TRIGGER_ENTRY_CLOSE_TP_INVALIDATE, + TRIGGER_ENTRY_MONITOR_TYPE, + TRIGGER_ENTRY_VALIDITY_HOURS, + check_trigger_entry_intent_limit, + count_pending_trigger_entries, + is_trigger_entry_expired, + is_trigger_entry_key_monitor_type, + trigger_entry_expires_at_text, + trigger_entry_gate_preview, + trigger_entry_invalidate_by_tp, + trigger_entry_reached, + validate_trigger_entry_geometry, + validate_trigger_entry_rr, +) +""" + +def patch(path: Path, block: str) -> None: + text = path.read_text(encoding="utf-8") + if "trigger_entry_key_monitor_lib" not in text: + text = text.replace("from position_sizing_lib import (", TRIG_IMPORT + "from position_sizing_lib import (", 1) + if "OPEN_SOURCE_KEY_TRIGGER" not in text: + text = text.replace( + " OPEN_SOURCE_KEY_AUTO,\n OPEN_SOURCE_MANUAL,", + " OPEN_SOURCE_KEY_AUTO,\n OPEN_SOURCE_KEY_TRIGGER,\n OPEN_SOURCE_MANUAL,", + 1, + ) + reps = [ + (' "关键位假突破",\n) + STRATEGY_ENTRY_REASON_OPTIONS', ' "关键位假突破",\n "关键位触价开仓",\n) + STRATEGY_ENTRY_REASON_OPTIONS'), + (' ("key_false_breakout", "关键位假突破", {"segment": "key_false_breakout"}),\n)', ' ("key_false_breakout", "关键位假突破", {"segment": "key_false_breakout"}),\n ("key_trigger", "关键位触价开仓", {"segment": "key_trigger"}),\n)'), + (' "ALTER TABLE key_monitors ADD COLUMN last_rs_bar_ts INTEGER",\n ):', ' "ALTER TABLE key_monitors ADD COLUMN last_rs_bar_ts INTEGER",\n "ALTER TABLE key_monitors ADD COLUMN session_date TEXT",\n ):'), + (' if segment_key == "key_false_breakout":\n return kst == FALSE_BREAKOUT_MONITOR_TYPE\n return False', ' if segment_key == "key_false_breakout":\n return kst == FALSE_BREAKOUT_MONITOR_TYPE\n if segment_key == "key_trigger":\n return kst == TRIGGER_ENTRY_MONITOR_TYPE\n return False'), + (' "key_false_breakout": FALSE_BREAKOUT_MONITOR_TYPE,\n }', ' "key_false_breakout": FALSE_BREAKOUT_MONITOR_TYPE,\n "key_trigger": TRIGGER_ENTRY_MONITOR_TYPE,\n }'), + (" check_fib_key_monitors()\n _roll_cfg", " check_fib_key_monitors()\n check_trigger_entry_key_monitors()\n _roll_cfg"), + (' "SELECT id,symbol,monitor_type,direction,upper,lower,fib_entry_price,fib_limit_order_id,created_at FROM key_monitors"', ' "SELECT id,symbol,monitor_type,direction,upper,lower,fib_entry_price,fib_stop_loss,fib_take_profit,fib_limit_order_id,created_at FROM key_monitors"'), + (' is_fb = is_false_breakout_key_monitor_type(r["monitor_type"])\n if is_fib or is_fb:\n price = get_symbol_mark_price(r["symbol"])', ' is_fb = is_false_breakout_key_monitor_type(r["monitor_type"])\n is_te = is_trigger_entry_key_monitor_type(r["monitor_type"])\n if is_fib or is_fb or is_te:\n price = get_symbol_mark_price(r["symbol"])'), + ] + for a, b in reps: + if a not in text: + raise SystemExit(f"{path.name}: missing\n{a[:70]}") + text = text.replace(a, b, 1) + if " + (TRIGGER_ENTRY_MONITOR_TYPE,)" not in text: + text = text.replace( + " + (FALSE_BREAKOUT_MONITOR_TYPE,)\n )", + " + (FALSE_BREAKOUT_MONITOR_TYPE,)\n + (TRIGGER_ENTRY_MONITOR_TYPE,)\n )", + 1, + ) + te_anchor = ' fb_gate_ok = bool(prev.get("gate_ok"))\n elif (r["monitor_type"] or "").strip() in KEY_MONITOR_RS_TYPES:' + if te_anchor not in text: + raise SystemExit(f"{path.name}: te anchor") + text = text.replace(te_anchor, ' fb_gate_ok = bool(prev.get("gate_ok"))\n' + TE_BRANCH + ' elif (r["monitor_type"] or "").strip() in KEY_MONITOR_RS_TYPES:', 1) + add_anchor = " if tc_en and not tc_h:\n tc_en = 0\n if is_false_breakout_key_monitor_type(mt):" + if add_anchor not in text: + raise SystemExit(f"{path.name}: add anchor") + text = text.replace( + add_anchor, + " if tc_en and not tc_h:\n tc_en = 0\n if is_trigger_entry_key_monitor_type(mt):" + ADD_BRANCH + " if is_false_breakout_key_monitor_type(mt):", + 1, + ) + func_anchor = " send_wechat_msg(succ)\n _finalize_key_monitor_one_shot(conn, row, succ, close_reason)\n\n\ndef check_fib_key_monitors():" + if func_anchor not in text: + raise SystemExit(f"{path.name}: func anchor") + text = text.replace(func_anchor, " send_wechat_msg(succ)\n _finalize_key_monitor_one_shot(conn, row, succ, close_reason)\n\n\n" + block, 1) + path.write_text(text, encoding="utf-8") + print("patched", path.relative_to(ROOT)) + +def main(): + block = extract_block() + patch(ROOT / "crypto_monitor_binance" / "app.py", block) + patch(ROOT / "crypto_monitor_okx" / "app.py", block) + +if __name__ == "__main__": + main() diff --git a/scripts/patch_trigger_entry_finish.py b/scripts/patch_trigger_entry_finish.py new file mode 100644 index 0000000..9a5153a --- /dev/null +++ b/scripts/patch_trigger_entry_finish.py @@ -0,0 +1,111 @@ +"""补全 okx / binance 触价开仓:import、stats、background、add_key。""" +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +GATE = ROOT / "crypto_monitor_gate" / "app.py" + +ADD_INSERT = GATE.read_text(encoding="utf-8").split( + " if is_trigger_entry_key_monitor_type(mt):" +)[1].split(" if is_false_breakout_key_monitor_type(mt):")[0] + +TRIG_IMPORT = """from trigger_entry_key_monitor_lib import ( + TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED, + TRIGGER_ENTRY_CLOSE_EXPIRED, + TRIGGER_ENTRY_CLOSE_FILLED, + TRIGGER_ENTRY_CLOSE_TP_INVALIDATE, + TRIGGER_ENTRY_MONITOR_TYPE, + TRIGGER_ENTRY_VALIDITY_HOURS, + check_trigger_entry_intent_limit, + count_pending_trigger_entries, + is_trigger_entry_expired, + is_trigger_entry_key_monitor_type, + trigger_entry_expires_at_text, + trigger_entry_gate_preview, + trigger_entry_invalidate_by_tp, + trigger_entry_reached, + validate_trigger_entry_geometry, + validate_trigger_entry_rr, +) +""" + +def patch_okx(): + p = ROOT / "crypto_monitor_okx" / "app.py" + t = p.read_text(encoding="utf-8") + if "trigger_entry_key_monitor_lib" not in t: + t = t.replace("from position_sizing_lib import (", TRIG_IMPORT + "from position_sizing_lib import (", 1) + if "OPEN_SOURCE_KEY_TRIGGER" not in t: + t = t.replace( + " OPEN_SOURCE_KEY_AUTO,\n OPEN_SOURCE_MANUAL,", + " OPEN_SOURCE_KEY_AUTO,\n OPEN_SOURCE_KEY_TRIGGER,\n OPEN_SOURCE_MANUAL,", + 1, + ) + if '"关键位触价开仓"' not in t: + t = t.replace( + ' "关键位假突破",\n) + STRATEGY_ENTRY_REASON_OPTIONS', + ' "关键位假突破",\n "关键位触价开仓",\n) + STRATEGY_ENTRY_REASON_OPTIONS', + 1, + ) + if "key_trigger" not in t: + t = t.replace( + ' ("key_false_breakout", "关键位假突破", {"segment": "key_false_breakout"}),\n)', + ' ("key_false_breakout", "关键位假突破", {"segment": "key_false_breakout"}),\n ("key_trigger", "关键位触价开仓", {"segment": "key_trigger"}),\n)', + 1, + ) + if "key_monitors ADD COLUMN session_date" not in t: + t = t.replace( + ' "ALTER TABLE key_monitors ADD COLUMN last_rs_bar_ts INTEGER",\n ):', + ' "ALTER TABLE key_monitors ADD COLUMN last_rs_bar_ts INTEGER",\n "ALTER TABLE key_monitors ADD COLUMN session_date TEXT",\n ):', + 1, + ) + if 'segment_key == "key_trigger"' not in t: + t = t.replace( + ' if segment_key == "key_false_breakout":\n return kst == FALSE_BREAKOUT_MONITOR_TYPE\n return False', + ' if segment_key == "key_false_breakout":\n return kst == FALSE_BREAKOUT_MONITOR_TYPE\n if segment_key == "key_trigger":\n return kst == TRIGGER_ENTRY_MONITOR_TYPE\n return False', + 1, + ) + if '"key_trigger":' not in t: + t = t.replace( + ' "key_false_breakout": FALSE_BREAKOUT_MONITOR_TYPE,\n }', + ' "key_false_breakout": FALSE_BREAKOUT_MONITOR_TYPE,\n "key_trigger": TRIGGER_ENTRY_MONITOR_TYPE,\n }', + 1, + ) + if "check_trigger_entry_key_monitors()" not in t: + t = t.replace( + " check_fib_key_monitors()\n _roll_cfg", + " check_fib_key_monitors()\n check_trigger_entry_key_monitors()\n _roll_cfg", + 1, + ) + if " + (TRIGGER_ENTRY_MONITOR_TYPE,)" not in t: + t = t.replace( + " + (FALSE_BREAKOUT_MONITOR_TYPE,)\n )", + " + (FALSE_BREAKOUT_MONITOR_TYPE,)\n + (TRIGGER_ENTRY_MONITOR_TYPE,)\n )", + 1, + ) + anchor = " be_flag = parse_breakeven_enabled_form(d.get(\"breakeven_enabled\"))\n if is_false_breakout_key_monitor_type(mt):" + if "is_trigger_entry_key_monitor_type(mt)" not in t: + t = t.replace( + anchor, + " be_flag = parse_breakeven_enabled_form(d.get(\"breakeven_enabled\"))\n tc_en = parse_time_close_enabled_form(d.get(\"time_close_enabled\"))\n tc_h = parse_time_close_hours_form(d.get(\"time_close_hours\")) if tc_en else None\n if tc_en and not tc_h:\n tc_en = 0\n if is_trigger_entry_key_monitor_type(mt):" + ADD_INSERT + " if is_false_breakout_key_monitor_type(mt):", + 1, + ) + p.write_text(t, encoding="utf-8") + print("okx done") + +def patch_binance_add_key(): + p = ROOT / "crypto_monitor_binance" / "app.py" + t = p.read_text(encoding="utf-8") + anchor = " be_flag = parse_breakeven_enabled_form(d.get(\"breakeven_enabled\"))\n if is_false_breakout_key_monitor_type(mt):" + if "is_trigger_entry_key_monitor_type(mt)" in t: + print("binance add_key skip") + return + t = t.replace( + anchor, + " be_flag = parse_breakeven_enabled_form(d.get(\"breakeven_enabled\"))\n tc_en = parse_time_close_enabled_form(d.get(\"time_close_enabled\"))\n tc_h = parse_time_close_hours_form(d.get(\"time_close_hours\")) if tc_en else None\n if tc_en and not tc_h:\n tc_en = 0\n if is_trigger_entry_key_monitor_type(mt):" + ADD_INSERT + " if is_false_breakout_key_monitor_type(mt):", + 1, + ) + p.write_text(t, encoding="utf-8") + print("binance add_key done") + +if __name__ == "__main__": + patch_okx() + patch_binance_add_key() diff --git a/scripts/patch_trigger_entry_to_exchanges.py b/scripts/patch_trigger_entry_to_exchanges.py new file mode 100644 index 0000000..3dcd72d --- /dev/null +++ b/scripts/patch_trigger_entry_to_exchanges.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +"""将触价开仓补丁同步到四所 app.py(与 crypto_monitor_gate 对齐)。""" +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +GATE = ROOT / "crypto_monitor_gate" / "app.py" +TARGETS = [ + ROOT / "crypto_monitor_gate_bot" / "app.py", + ROOT / "crypto_monitor_binance" / "app.py", + ROOT / "crypto_monitor_okx" / "app.py", +] + +# 从 gate 提取触价相关函数块 +FUNC_BLOCK_START = "def _trigger_entry_exists_for_symbol(conn, symbol):" +FUNC_BLOCK_END = "def check_fib_key_monitors():" + + +def extract_gate_block() -> str: + text = GATE.read_text(encoding="utf-8") + i = text.index(FUNC_BLOCK_START) + j = text.index(FUNC_BLOCK_END) + return text[i:j] + + +def ensure_imports(text: str) -> str: + trigger_import = """from trigger_entry_key_monitor_lib import ( + TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED, + TRIGGER_ENTRY_CLOSE_EXPIRED, + TRIGGER_ENTRY_CLOSE_FILLED, + TRIGGER_ENTRY_CLOSE_TP_INVALIDATE, + TRIGGER_ENTRY_MONITOR_TYPE, + TRIGGER_ENTRY_VALIDITY_HOURS, + check_trigger_entry_intent_limit, + count_pending_trigger_entries, + is_trigger_entry_expired, + is_trigger_entry_key_monitor_type, + trigger_entry_expires_at_text, + trigger_entry_gate_preview, + trigger_entry_invalidate_by_tp, + trigger_entry_reached, + validate_trigger_entry_geometry, + validate_trigger_entry_rr, +) +""" + if "from trigger_entry_key_monitor_lib import" not in text: + text = text.replace( + "from position_sizing_lib import (", + trigger_import + "from position_sizing_lib import (", + 1, + ) + if "OPEN_SOURCE_KEY_TRIGGER" not in text: + text = text.replace( + " OPEN_SOURCE_KEY_AUTO,\n OPEN_SOURCE_MANUAL,", + " OPEN_SOURCE_KEY_AUTO,\n OPEN_SOURCE_KEY_TRIGGER,\n OPEN_SOURCE_MANUAL,", + 1, + ) + return text + + +def patch_file(path: Path, func_block: str) -> None: + text = path.read_text(encoding="utf-8") + text = ensure_imports(text) + + replacements = [ + ( + ' "关键位假突破",\n) + STRATEGY_ENTRY_REASON_OPTIONS', + ' "关键位假突破",\n "关键位触价开仓",\n) + STRATEGY_ENTRY_REASON_OPTIONS', + ), + ( + ' ("key_false_breakout", "关键位假突破", {"segment": "key_false_breakout"}),\n)', + ' ("key_false_breakout", "关键位假突破", {"segment": "key_false_breakout"}),\n ("key_trigger", "关键位触价开仓", {"segment": "key_trigger"}),\n)', + ), + ( + ' "ALTER TABLE key_monitors ADD COLUMN last_rs_bar_ts INTEGER",\n ):', + ' "ALTER TABLE key_monitors ADD COLUMN last_rs_bar_ts INTEGER",\n "ALTER TABLE key_monitors ADD COLUMN session_date TEXT",\n ):', + ), + ( + ' if segment_key == "key_false_breakout":\n return kst == FALSE_BREAKOUT_MONITOR_TYPE\n return False', + ' if segment_key == "key_false_breakout":\n return kst == FALSE_BREAKOUT_MONITOR_TYPE\n if segment_key == "key_trigger":\n return kst == TRIGGER_ENTRY_MONITOR_TYPE\n return False', + ), + ( + ' "key_false_breakout": FALSE_BREAKOUT_MONITOR_TYPE,\n }', + ' "key_false_breakout": FALSE_BREAKOUT_MONITOR_TYPE,\n "key_trigger": TRIGGER_ENTRY_MONITOR_TYPE,\n }', + ), + ( + " check_fib_key_monitors()\n _roll_cfg", + " check_fib_key_monitors()\n check_trigger_entry_key_monitors()\n _roll_cfg", + ), + ( + " + (FALSE_BREAKOUT_MONITOR_TYPE,)\n )", + " + (FALSE_BREAKOUT_MONITOR_TYPE,)\n + (TRIGGER_ENTRY_MONITOR_TYPE,)\n )", + ), + ( + ' "SELECT id,symbol,monitor_type,direction,upper,lower,fib_entry_price,fib_limit_order_id,created_at FROM key_monitors"', + ' "SELECT id,symbol,monitor_type,direction,upper,lower,fib_entry_price,fib_stop_loss,fib_take_profit,fib_limit_order_id,created_at FROM key_monitors"', + ), + ( + " is_fb = is_false_breakout_key_monitor_type(r[\"monitor_type\"])\n if is_fib or is_fb:\n price = get_symbol_mark_price(r[\"symbol\"])", + " is_fb = is_false_breakout_key_monitor_type(r[\"monitor_type\"])\n is_te = is_trigger_entry_key_monitor_type(r[\"monitor_type\"])\n if is_fib or is_fb or is_te:\n price = get_symbol_mark_price(r[\"symbol\"])", + ), + ] + for old, new in replacements: + if old not in text: + raise SystemExit(f"[{path.name}] missing pattern:\n{old[:80]}...") + text = text.replace(old, new, 1) + + # api_price_snapshot te branch + te_branch_old = """ gate_summary = prev.get("summary") or "-" + gate_metrics = prev.get("metrics") or "" + fb_gate_ok = bool(prev.get("gate_ok")) + elif (r["monitor_type"] or "").strip() in KEY_MONITOR_RS_TYPES:""" + te_branch_new = """ gate_summary = prev.get("summary") or "-" + gate_metrics = prev.get("metrics") or "" + fb_gate_ok = bool(prev.get("gate_ok")) + elif is_te: + direction = (r["direction"] or "long").lower() + entry = _sqlite_row_val(r, "fib_entry_price") + tp_v = _sqlite_row_val(r, "fib_take_profit") + entry_txt = format_price_for_symbol(r["symbol"], entry) if entry else "-" + tp_txt = format_price_for_symbol(r["symbol"], tp_v) if tp_v else "-" + tp_inv = trigger_entry_invalidate_by_tp(direction, price, float(tp_v)) if tp_v else False + prev = trigger_entry_gate_preview( + entry_display=entry_txt, + take_profit_display=tp_txt, + created_at=_sqlite_row_val(r, "created_at"), + now=app_now(), + tp_invalidated=tp_inv, + hours=TRIGGER_ENTRY_VALIDITY_HOURS, + ) + gate_summary = prev.get("summary") or "-" + gate_metrics = prev.get("metrics") or "" + fib_gate_ok = bool(prev.get("gate_ok")) + elif (r["monitor_type"] or "").strip() in KEY_MONITOR_RS_TYPES:""" + if te_branch_old not in text: + raise SystemExit(f"[{path.name}] missing api te branch anchor") + text = text.replace(te_branch_old, te_branch_new, 1) + + # add_key trigger branch + add_key_old = """ if tc_en and not tc_h: + tc_en = 0 + if is_false_breakout_key_monitor_type(mt):""" + add_key_new = """ if tc_en and not tc_h: + tc_en = 0 + if is_trigger_entry_key_monitor_type(mt): + if direction_sel not in ("long", "short"): + conn.close() + conn = None + flash("触价开仓请选择做多或做空") + return redirect("/key_monitor") + try: + entry_px = float(d.get("trigger_entry") or 0) + sl_px = float(d.get("trigger_sl") or 0) + tp_px = float(d.get("trigger_tp") or 0) + except (TypeError, ValueError): + entry_px = sl_px = tp_px = 0 + if entry_px <= 0 or sl_px <= 0 or tp_px <= 0: + conn.close() + conn = None + flash("触价开仓须填写有效的入场价、止损价、止盈价") + return redirect("/key_monitor") + ok_te, err_te = _add_trigger_entry_key_monitor( + conn, symbol, direction_sel, entry_px, sl_px, tp_px, breakeven_enabled=be_flag, + time_close_enabled=tc_en, time_close_hours=tc_h, + ) + conn.commit() + conn.close() + conn = None + if not ok_te: + flash(err_te or "触价开仓监控添加失败") + return redirect("/key_monitor") + flash( + f"触价开仓已添加({symbol} 日成交量排名 {rank}/{total})" + f"|有效期 {TRIGGER_ENTRY_VALIDITY_HOURS}h" + f"|标记价触达入场价后下一轮询市价开仓" + f"|移动保本:{'开' if be_flag else '关'}" + + (f"|{time_close_label(tc_h)}" if tc_en else "") + ) + return redirect("/key_monitor") + if is_false_breakout_key_monitor_type(mt):""" + if add_key_old not in text: + raise SystemExit(f"[{path.name}] missing add_key anchor") + text = text.replace(add_key_old, add_key_new, 1) + + # function block + anchor = " send_wechat_msg(succ)\n _finalize_key_monitor_one_shot(conn, row, succ, close_reason)\n\n\ndef check_fib_key_monitors():" + if anchor not in text: + raise SystemExit(f"[{path.name}] missing func insert anchor") + text = text.replace( + anchor, + " send_wechat_msg(succ)\n _finalize_key_monitor_one_shot(conn, row, succ, close_reason)\n\n\n" + func_block, + 1, + ) + + path.write_text(text, encoding="utf-8") + print(f"patched {path.relative_to(ROOT)}") + + +def main() -> None: + block = extract_gate_block() + for t in TARGETS: + patch_file(t, block) + print("done") + + +if __name__ == "__main__": + main() diff --git a/strategy_templates/key_monitor_panel.html b/strategy_templates/key_monitor_panel.html index 767026f..9feec74 100644 --- a/strategy_templates/key_monitor_panel.html +++ b/strategy_templates/key_monitor_panel.html @@ -93,7 +93,7 @@ {% macro key_history_outcome_kind(h) -%} {%- set r = (h.close_reason or '')|trim -%} -{%- if r in ['fib_filled', 'false_breakout_filled', 'key_level_alert_done', 'alerts_complete', 'auto_opened'] -%}success +{%- if r in ['fib_filled', 'false_breakout_filled', 'trigger_entry_filled', 'key_level_alert_done', 'alerts_complete', 'auto_opened'] -%}success {%- elif r == 'manual' -%}manual {%- elif r -%}failed {%- else -%}neutral @@ -104,11 +104,15 @@ {%- set r = (h.close_reason or '')|trim -%} {%- if r == 'fib_filled' -%}斐波成交 {%- elif r == 'false_breakout_filled' -%}假突破成交 +{%- elif r == 'trigger_entry_filled' -%}触价成交 {%- elif r == 'key_level_alert_done' -%}提醒完成 {%- elif r == 'alerts_complete' -%}提醒已满 {%- elif r == 'auto_opened' -%}自动开仓 {%- elif r == 'manual' -%}手动删除 {%- elif r == 'fib_invalidate' -%}斐波失效 +{%- elif r == 'trigger_tp_invalidate' -%}触价止盈失效 +{%- elif r == 'trigger_entry_expired' -%}触价过期 +{%- elif r == 'trigger_exchange_failed' -%}触价下单失败 {%- elif r == 'false_breakout_expired' -%}假突破过期 {%- elif r == 'fib_plan_invalid' -%}计划无效 {%- elif r == 'rr_insufficient' -%}盈亏比不足 @@ -133,12 +137,15 @@