From 44432a06888cfdd3ae5352bd81222791b32a6173 Mon Sep 17 00:00:00 2001 From: dekun Date: Tue, 19 May 2026 15:20:14 +0800 Subject: [PATCH] =?UTF-8?q?bot=E5=A2=9E=E5=8A=A0=E4=BF=9D=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crypto_monitor_gate_bot/.env.example | 2 + crypto_monitor_gate_bot/app.py | 183 ++++++++++++++++++- crypto_monitor_gate_bot/templates/index.html | 79 +++++++- crypto_monitor_gate_bot/趋势回调策略说明.md | 13 +- 4 files changed, 268 insertions(+), 9 deletions(-) diff --git a/crypto_monitor_gate_bot/.env.example b/crypto_monitor_gate_bot/.env.example index 9f2b4c8..7a57460 100644 --- a/crypto_monitor_gate_bot/.env.example +++ b/crypto_monitor_gate_bot/.env.example @@ -141,6 +141,8 @@ AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest # 趋势回调策略(可选,见 趋势回调策略说明.md) # TREND_PULLBACK_DCA_LEGS=5 # TREND_PULLBACK_PREVIEW_TTL_SECONDS=120 +# 趋势回调手动保本:相对持仓均价的默认偏移(%);多=均价×(1+pct/100),空=均价×(1-pct/100) +# TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT=0.3 # TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT=5 APP_TIMEZONE=Asia/Shanghai diff --git a/crypto_monitor_gate_bot/app.py b/crypto_monitor_gate_bot/app.py index e3fe4ec..fbf48e6 100644 --- a/crypto_monitor_gate_bot/app.py +++ b/crypto_monitor_gate_bot/app.py @@ -29,6 +29,19 @@ except ImportError: ImageFont = None # type: ignore BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +_REPO_ROOT = os.path.dirname(BASE_DIR) +import sys + +if _REPO_ROOT not in sys.path: + sys.path.insert(0, _REPO_ROOT) +from history_window_lib import ( + PRESET_CUSTOM, + PRESET_UTC_LAST24H, + PRESET_UTC_LAST7D, + PRESET_UTC_TODAY, + resolve_window, + utc_window_to_bj_sql_strings, +) def load_env_file(path): @@ -157,6 +170,10 @@ TREND_PULLBACK_DCA_LEGS = max(1, int(os.getenv("TREND_PULLBACK_DCA_LEGS", "5"))) TREND_PULLBACK_PREVIEW_TTL_SECONDS = max(10, int(os.getenv("TREND_PULLBACK_PREVIEW_TTL_SECONDS", "120"))) # 确认执行时:当前可用余额与预览快照相对偏差超过该百分比则拒绝(避免余额被划走后仍按旧计划满仓) TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT = float(os.getenv("TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT", "5")) +# 趋势回调:手动保本默认相对均价偏移(%);多=均价×(1+pct/100),空=均价×(1-pct/100) +TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT = float( + os.getenv("TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT", "0.3") +) MONITOR_TYPE_TREND = "趋势回调" KLINE_TIMEFRAME = os.getenv("KLINE_TIMEFRAME", "5m") FULL_MARGIN_BUFFER_RATIO = float(os.getenv("FULL_MARGIN_BUFFER_RATIO", "0.98")) @@ -1294,6 +1311,15 @@ def init_db(): c.execute("ALTER TABLE trend_pullback_plans ADD COLUMN leg_amounts_json TEXT") except Exception: pass + for ddl in ( + "ALTER TABLE trend_pullback_plans ADD COLUMN initial_stop_loss REAL", + "ALTER TABLE trend_pullback_plans ADD COLUMN breakeven_applied INTEGER DEFAULT 0", + "ALTER TABLE trend_pullback_plans ADD COLUMN breakeven_applied_at TEXT", + ): + try: + c.execute(ddl) + except Exception: + pass c.execute( """CREATE TABLE IF NOT EXISTS trend_pullback_previews ( @@ -3514,8 +3540,35 @@ def trend_plan_history_status_label(status): }.get(s, status or "-") +def _list_window_from_request(): + return resolve_window(request.args, default_preset=PRESET_UTC_TODAY) + + +def calc_trend_manual_breakeven_stop(direction, entry_price, offset_pct=None): + """趋势回调手动保本:默认开仓均价 + offset_pct%(多上移、空下移)。""" + try: + e = float(entry_price) + pct = float( + offset_pct + if offset_pct is not None + else TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT + ) + except (TypeError, ValueError): + return None + if e <= 0: + return None + direction = (direction or "long").strip().lower() + if direction == "short": + return e * (1.0 - pct / 100.0) + return e * (1.0 + pct / 100.0) + + def enrich_active_trend_plan_row(row): d = row_to_dict(row) + try: + d["breakeven_applied"] = int(d.get("breakeven_applied") or 0) != 0 + except Exception: + d["breakeven_applied"] = False ex_sym = d.get("exchange_symbol") or normalize_exchange_symbol(d.get("symbol") or "") direction = (d.get("direction") or "long").lower() m = get_live_position_exchange_metrics(ex_sym, direction) @@ -4225,6 +4278,66 @@ def _trend_refresh_stop_only(exchange_symbol, direction, stop_loss): _gate_place_stop_loss_only_position(exchange_symbol, direction, stop_loss) +def apply_trend_pullback_manual_breakeven(conn, row, offset_pct=None): + """运行中趋势计划:将交易所止损移至均价+偏移(默认 0.3%),仅当新止损更优时生效。""" + if (row["status"] or "").strip() != "active": + return False, "计划已结束" + if not int(row["first_order_done"] or 0): + return False, "尚未完成首仓,无法保本" + avg_e = float(row["avg_entry_price"] or 0) + if avg_e <= 0: + return False, "缺少有效持仓均价" + direction = (row["direction"] or "long").lower() + ex_sym = row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"]) + pos = get_live_position_contracts(ex_sym, direction) + if pos is None or float(pos) <= 0: + return False, "交易所当前无该方向持仓" + new_sl_raw = calc_trend_manual_breakeven_stop(direction, avg_e, offset_pct) + if new_sl_raw is None: + return False, "保本价计算失败" + new_sl = round_price_to_exchange(ex_sym, new_sl_raw) + if new_sl is None: + return False, "保本价经交易所精度舍入后无效" + new_sl = float(new_sl) + cur_sl = float(row["stop_loss"] or 0) + if direction == "long": + if new_sl <= cur_sl: + return False, f"新止损 {new_sl} 未高于当前止损 {cur_sl}(多仓需上移)" + else: + if new_sl >= cur_sl: + return False, f"新止损 {new_sl} 未低于当前止损 {cur_sl}(空仓需下移)" + try: + _trend_refresh_stop_only(ex_sym, direction, new_sl) + except Exception as e: + return False, friendly_exchange_error(e) + now_s = app_now_str() + conn.execute( + "UPDATE trend_pullback_plans SET stop_loss=?, breakeven_applied=1, breakeven_applied_at=? WHERE id=?", + (new_sl, now_s, row["id"]), + ) + pct_used = float( + offset_pct + if offset_pct is not None + else TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT + ) + sym = row["symbol"] + send_wechat_msg( + "\n".join( + [ + f"# ✅ {sym} 趋势回调手动保本", + f"**账户:{_wechat_account_label()}**", + f"- 计划 ID:**{row['id']}**", + f"- 方向:{_wechat_direction_text(direction)}", + f"- 持仓均价:{format_price_for_symbol(sym, avg_e)}", + f"- 偏移:{pct_used}%(相对均价)", + f"- 新止损:{format_price_for_symbol(sym, new_sl)}", + f"- 交易所:已更新仓位止损触发单", + ] + ) + ) + return True, None + + def _trend_weighted_avg(old_avg, old_amt, fill_px, add_amt): try: oa, aa = float(old_amt), float(add_amt) @@ -4937,6 +5050,8 @@ def api_sync_positions(): def render_main_page(page="trade"): now = app_now() trading_day = get_trading_day(now) + list_window = _list_window_from_request() + start_bj, end_bj = utc_window_to_bj_sql_strings(list_window["start_utc"], list_window["end_utc"], APP_TZ) conn = get_db() session_row = ensure_session(conn, trading_day) local_current_capital = float(session_row["current_capital"]) @@ -4957,7 +5072,14 @@ def render_main_page(page="trade"): sync_trend_trade_records_from_exchange(conn) except Exception: pass - raw_records = conn.execute("SELECT * FROM trade_records ORDER BY id DESC").fetchall() + if page in ("records", "plan_history"): + raw_records = conn.execute( + "SELECT * FROM trade_records WHERE COALESCE(closed_at, created_at, opened_at) >= ? " + "AND COALESCE(closed_at, created_at, opened_at) <= ? ORDER BY id DESC LIMIT 2000", + (start_bj, end_bj), + ).fetchall() + else: + raw_records = conn.execute("SELECT * FROM trade_records ORDER BY id DESC LIMIT 500").fetchall() records = [to_effective_trade_dict(r) for r in raw_records] total = len(records) miss_count = sum(1 for r in records if (r.get("effective_result") or "") == "错过") @@ -4981,14 +5103,18 @@ def render_main_page(page="trade"): preview_snapshots = [] if page == "plan_history": plan_history_raw = conn.execute( - "SELECT * FROM trend_pullback_plans WHERE status != 'active' ORDER BY id DESC LIMIT 100" + "SELECT * FROM trend_pullback_plans WHERE status != 'active' " + "AND COALESCE(opened_at, '') >= ? AND COALESCE(opened_at, '') <= ? ORDER BY id DESC LIMIT 500", + (start_bj, end_bj), ).fetchall() for pr in plan_history_raw: pd = row_to_dict(pr) pd["status_label"] = trend_plan_history_status_label(pd.get("status")) plan_history.append(pd) snap_rows = conn.execute( - "SELECT * FROM trend_pullback_preview_snapshots ORDER BY id DESC LIMIT 150" + "SELECT * FROM trend_pullback_preview_snapshots WHERE COALESCE(preview_created_at, '') >= ? " + "AND COALESCE(preview_created_at, '') <= ? ORDER BY id DESC LIMIT 500", + (start_bj, end_bj), ).fetchall() for sr in snap_rows: sd = row_to_dict(sr) @@ -5067,6 +5193,14 @@ def render_main_page(page="trade"): trend_preview_expired=trend_preview_expired, trend_preview_id_arg=trend_preview_id_arg, trend_preview_max_drift_pct=TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT, + trend_manual_breakeven_offset_pct=TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT, + list_window=list_window, + list_window_presets={ + "utc_today": PRESET_UTC_TODAY, + "utc_last24h": PRESET_UTC_LAST24H, + "utc_last7d": PRESET_UTC_LAST7D, + "custom": PRESET_CUSTOM, + }, focus_key_id=(key_list[0]["id"] if key_list else None), focus_order_id=(order_list[0]["id"] if order_list else None), data_export_version=3, @@ -6019,10 +6153,10 @@ def execute_trend_pullback(): opened_ms = _to_ms_with_fallback(None, opened_at) cur = conn.execute( """INSERT INTO trend_pullback_plans ( - status,symbol,exchange_symbol,direction,leverage,stop_loss,add_upper,take_profit,risk_percent, + status,symbol,exchange_symbol,direction,leverage,stop_loss,initial_stop_loss,add_upper,take_profit,risk_percent, snapshot_available_usdt,snapshot_at,plan_margin_capital,target_order_amount,first_order_amount,remainder_total, dca_legs,per_leg_amount,grid_prices_json,leg_amounts_json,legs_done,first_order_done,last_mark_price,avg_entry_price,order_amount_open,opened_at,opened_at_ms,session_date,message - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( "active", symbol, @@ -6030,6 +6164,7 @@ def execute_trend_pullback(): direction, leverage, stop_loss, + stop_loss, add_upper, take_profit, risk_percent, @@ -6086,6 +6221,37 @@ def cancel_trend_pullback_preview(): return redirect(url_for("trade_page")) +@app.route("/trend_pullback_breakeven/", methods=["POST"]) +@login_required +def trend_pullback_breakeven(pid): + offset_raw = (request.form.get("breakeven_offset_pct") or "").strip() + offset_pct = None + if offset_raw: + try: + offset_pct = float(offset_raw) + if offset_pct < 0: + raise ValueError + except ValueError: + flash("保本偏移% 格式无效") + return redirect(url_for("trade_page")) + conn = get_db() + row = conn.execute( + "SELECT * FROM trend_pullback_plans WHERE id=? AND status='active'", (pid,) + ).fetchone() + if not row: + conn.close() + flash("未找到运行中的趋势回调计划") + return redirect(url_for("trade_page")) + ok, err = apply_trend_pullback_manual_breakeven(conn, row, offset_pct=offset_pct) + conn.commit() + conn.close() + if ok: + flash("已手动保本:交易所止损已按均价+偏移更新") + else: + flash(err or "手动保本失败") + return redirect(url_for("trade_page")) + + @app.route("/stop_trend_pullback/") @login_required def stop_trend_pullback(pid): @@ -6222,12 +6388,17 @@ def _md_response(filename, content): @app.route("/export/trade_records") @login_required def export_trade_records(): + win = _list_window_from_request() + start_bj, end_bj = utc_window_to_bj_sql_strings(win["start_utc"], win["end_utc"], APP_TZ) conn = get_db() rows = conn.execute( "SELECT id,symbol,monitor_type,direction,trigger_price,stop_loss,take_profit,margin_capital,leverage," "pnl_amount,hold_seconds,hold_minutes,opened_at,closed_at,result,miss_reason," "entry_reason,reviewed_entry_reason,created_at,trend_plan_id,exchange_realized_pnl," - "exchange_opened_at,exchange_closed_at,exchange_sync_key FROM trade_records ORDER BY id ASC" + "exchange_opened_at,exchange_closed_at,exchange_sync_key FROM trade_records " + "WHERE COALESCE(closed_at, created_at, opened_at) >= ? " + "AND COALESCE(closed_at, created_at, opened_at) <= ? ORDER BY id ASC", + (start_bj, end_bj), ).fetchall() conn.close() head_base = [ diff --git a/crypto_monitor_gate_bot/templates/index.html b/crypto_monitor_gate_bot/templates/index.html index edea9c2..c656975 100644 --- a/crypto_monitor_gate_bot/templates/index.html +++ b/crypto_monitor_gate_bot/templates/index.html @@ -136,6 +136,8 @@ .export-bar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:12px;font-size:.85rem} .export-bar a{color:#8fc8ff;text-decoration:none;padding:6px 10px;border:1px solid #304164;border-radius:8px;background:#151a2a} .export-bar a:hover{background:#1f2740} + .list-window-bar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:12px;padding:10px 12px;background:#151a2a;border:1px solid #304164;border-radius:10px;font-size:.82rem} + .list-window-bar label{color:#9aa;display:flex;align-items:center;gap:6px} .key-history{margin-top:12px;padding-top:10px;border-top:1px solid #2a3150} .key-history h3{font-size:.88rem;color:#b8c4ff;margin-bottom:6px} .key-history .sub{font-size:.72rem;color:#8892b0;margin-bottom:6px} @@ -198,6 +200,27 @@ {% with msg=get_flashed_messages() %}{% if msg %}
{{ msg[0] }}
{% endif %}{% endwith %} + + {% if page in ('records', 'plan_history') %} +
+ 列表筛选(UTC,默认当日):{{ list_window.label }} + + + + + + + 统计页仍按北京时间 {{ reset_hour }}:00 切日 +
+ {% endif %} +
数据导出(v{{ data_export_version }} CSV,UTF-8;交易记录含开仓类型列及交易所对齐字段): 交易记录 @@ -339,7 +362,7 @@

趋势回调策略

生成预览:读取合约 USDT 可用余额快照并计算计划(不下单)。预览有效期 {{ trend_pullback_preview_ttl }} 秒
- ② 确认执行:市价首仓 50% + 挂交易所止损;剩余 50% 在止损与补仓区间之间共 {{ trend_pullback_dca_legs }} 档(做多为上沿、做空为下沿;程序可能因最小张数自动减档)市价补仓;止盈由程序监控
+ ② 确认执行:市价首仓 50% + 挂交易所止损;首仓后可手动保本(默认均价+{{ trend_manual_breakeven_offset_pct }}%);剩余 50% 在止损与补仓区间之间共 {{ trend_pullback_dca_legs }} 档(做多为上沿、做空为下沿;程序可能因最小张数自动减档)市价补仓;止盈由程序监控
确认执行时若当前可用余额与预览快照相对偏差 > {{ trend_preview_max_drift_pct }}% 会拒绝并要求重新预览。
@@ -357,6 +380,45 @@