From d467760d5cf4fc8013d6b2e1a496aff524ec24ec Mon Sep 17 00:00:00 2001 From: dekun Date: Fri, 26 Jun 2026 22:03:23 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A1=BA=E5=8A=BF=E5=8A=A0=E4=BB=93=20v2?= =?UTF-8?q?=EF=BC=9A=E7=A8=8B=E5=BA=8F=E7=9B=91=E6=8E=A7=E6=BB=9A=E4=BB=93?= =?UTF-8?q?=E3=80=81=E6=96=87=E6=A1=A3=E9=A1=B5=E4=B8=8E=E5=B9=B3=E4=BB=93?= =?UTF-8?q?=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重写滚仓计仓与四种加仓方式(市价/斐波/突破),程序盯 mark 触价成交;风险读监控单;pending 可删不可改;手动平仓同步结束滚仓。新增 /strategy/roll/docs 说明页与顺势加仓滚仓说明.md。 Co-authored-by: Cursor --- crypto_monitor_binance/app.py | 16 + crypto_monitor_gate/app.py | 16 + crypto_monitor_gate_bot/app.py | 16 + crypto_monitor_okx/app.py | 16 + hub_bridge.py | 32 ++ manual_trading_hub/hub.py | 9 + static/strategy_roll.js | 182 ++++++++ strategy_db.py | 2 + strategy_register.py | 494 +++++++++++++------- strategy_roll_lib.py | 421 ++++++++++------- strategy_roll_monitor_lib.py | 442 +++++++++++++----- strategy_templates/strategy_roll.html | 111 +---- strategy_templates/strategy_roll_docs.html | 41 ++ strategy_templates/strategy_roll_panel.html | 81 ++-- tests/test_strategy_roll_lib.py | 81 ++-- 策略交易说明.md | 2 + 顺势加仓滚仓说明.md | 174 +++++++ 17 files changed, 1506 insertions(+), 630 deletions(-) create mode 100644 static/strategy_roll.js create mode 100644 strategy_templates/strategy_roll_docs.html create mode 100644 顺势加仓滚仓说明.md diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index fade64c..240ea0e 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -8673,6 +8673,14 @@ def del_order(id): now=app_now(), ) conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, id)) + try: + _rcfg = app.extensions.get("strategy_roll_cfg") + if isinstance(_rcfg, dict): + from strategy_register import roll_sync_after_external_close + + roll_sync_after_external_close(_rcfg, conn, row["symbol"], row["direction"]) + except Exception: + pass clear_key_sizing_snapshot_if_flat(conn, session_date) conn.commit() conn.close() @@ -8740,6 +8748,14 @@ def del_order(id): now=app_now(), ) conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (id,)) + try: + _rcfg = app.extensions.get("strategy_roll_cfg") + if isinstance(_rcfg, dict): + from strategy_register import roll_sync_after_external_close + + roll_sync_after_external_close(_rcfg, conn, row["symbol"], row["direction"]) + except Exception: + pass conn.commit() conn.close() flash("该仓位在交易所已不存在,已按成交记录同步结束并记账") diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py index da2a23a..20ce98e 100644 --- a/crypto_monitor_gate/app.py +++ b/crypto_monitor_gate/app.py @@ -8602,6 +8602,14 @@ def del_order(id): now=app_now(), ) conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, id)) + try: + _rcfg = app.extensions.get("strategy_roll_cfg") + if isinstance(_rcfg, dict): + from strategy_register import roll_sync_after_external_close + + roll_sync_after_external_close(_rcfg, conn, row["symbol"], row["direction"]) + except Exception: + pass clear_key_sizing_snapshot_if_flat(conn, session_date) conn.commit() conn.close() @@ -8670,6 +8678,14 @@ def del_order(id): now=app_now(), ) conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (id,)) + try: + _rcfg = app.extensions.get("strategy_roll_cfg") + if isinstance(_rcfg, dict): + from strategy_register import roll_sync_after_external_close + + roll_sync_after_external_close(_rcfg, conn, row["symbol"], row["direction"]) + except Exception: + pass conn.commit() conn.close() flash("该仓位在交易所已不存在,已按成交记录同步结束并记账") diff --git a/crypto_monitor_gate_bot/app.py b/crypto_monitor_gate_bot/app.py index a9d3530..0f965e7 100644 --- a/crypto_monitor_gate_bot/app.py +++ b/crypto_monitor_gate_bot/app.py @@ -8598,6 +8598,14 @@ def del_order(id): now=app_now(), ) conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, id)) + try: + _rcfg = app.extensions.get("strategy_roll_cfg") + if isinstance(_rcfg, dict): + from strategy_register import roll_sync_after_external_close + + roll_sync_after_external_close(_rcfg, conn, row["symbol"], row["direction"]) + except Exception: + pass clear_key_sizing_snapshot_if_flat(conn, session_date) conn.commit() conn.close() @@ -8666,6 +8674,14 @@ def del_order(id): now=app_now(), ) conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (id,)) + try: + _rcfg = app.extensions.get("strategy_roll_cfg") + if isinstance(_rcfg, dict): + from strategy_register import roll_sync_after_external_close + + roll_sync_after_external_close(_rcfg, conn, row["symbol"], row["direction"]) + except Exception: + pass conn.commit() conn.close() flash("该仓位在交易所已不存在,已按成交记录同步结束并记账") diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index c4f681f..85efc25 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -8092,6 +8092,14 @@ def del_order(id): now=app_now(), ) conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, id)) + try: + _rcfg = app.extensions.get("strategy_roll_cfg") + if isinstance(_rcfg, dict): + from strategy_register import roll_sync_after_external_close + + roll_sync_after_external_close(_rcfg, conn, row["symbol"], row["direction"]) + except Exception: + pass conn.commit() conn.close() send_wechat_msg( @@ -8157,6 +8165,14 @@ def del_order(id): now=app_now(), ) conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (id,)) + try: + _rcfg = app.extensions.get("strategy_roll_cfg") + if isinstance(_rcfg, dict): + from strategy_register import roll_sync_after_external_close + + roll_sync_after_external_close(_rcfg, conn, row["symbol"], row["direction"]) + except Exception: + pass conn.commit() conn.close() flash("该仓位在交易所已不存在,已按成交记录同步结束并记账") diff --git a/hub_bridge.py b/hub_bridge.py index 86b6bdd..dcda902 100644 --- a/hub_bridge.py +++ b/hub_bridge.py @@ -791,6 +791,38 @@ def register_hub_routes(app): finally: conn.close() + @app.route("/api/hub/roll/sync-flat", methods=["POST"]) + @_hub_auth_required + def api_hub_roll_sync_flat(): + """中控/实例手动平仓后:取消滚仓 pending 并关闭 active 滚仓组。""" + body = request.get_json(silent=True) or {} + symbol = (body.get("symbol") or request.form.get("symbol") or "").strip() + side = ( + body.get("side") + or body.get("direction") + or request.form.get("side") + or "" + ).strip().lower() + if not symbol: + return jsonify({"ok": False, "msg": "symbol 不能为空"}), 400 + if side not in ("long", "short"): + return jsonify({"ok": False, "msg": "side 须为 long 或 short"}), 400 + cfg = current_app.extensions.get("strategy_roll_cfg") + get_db = _ctx().get("get_db") + if not cfg or not callable(get_db): + return jsonify({"ok": False, "msg": "滚仓配置未就绪"}), 500 + from strategy_register import roll_sync_after_external_close + + conn = get_db() + try: + out = roll_sync_after_external_close(cfg, conn, symbol, side) + conn.commit() + return jsonify(out) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + finally: + conn.close() + @app.route("/api/hub/trend/breakeven/", methods=["POST"]) @_hub_auth_required def api_hub_trend_breakeven(pid): diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index 9d7e5f9..35ee0f2 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -2143,6 +2143,15 @@ async def api_close_position(exchange_id: str, body: ClosePositionBody): ) if isinstance(sync_parsed, dict): out["trend_sync"] = sync_parsed + roll_sync = await _fetch_flask_json( + flask_client, + ex, + "/api/hub/roll/sync-flat", + method="POST", + json_body={"symbol": sym, "side": side}, + ) + if isinstance(roll_sync, dict): + out["roll_sync"] = roll_sync risk_sync = await _notify_instance_user_close(flask_client, ex, count=1) if isinstance(risk_sync, dict): out["risk_sync"] = risk_sync diff --git a/static/strategy_roll.js b/static/strategy_roll.js new file mode 100644 index 0000000..913e974 --- /dev/null +++ b/static/strategy_roll.js @@ -0,0 +1,182 @@ +(function () { + "use strict"; + + const form = document.getElementById("roll-form"); + if (!form) return; + + const symbolSel = document.getElementById("roll-symbol"); + const dirInput = document.getElementById("roll-direction"); + const modeSel = document.getElementById("roll-add-mode"); + const riskBanner = document.getElementById("roll-risk-banner"); + const previewBtn = document.getElementById("roll-preview-btn"); + const submitBtn = document.getElementById("roll-submit-btn"); + const previewBox = document.getElementById("roll-preview-box"); + const previewText = document.getElementById("roll-preview-text"); + const countdownEl = document.getElementById("roll-countdown"); + + let countdownTimer = null; + let previewOk = false; + let lastPreviewMode = ""; + + function qs(sel) { + return form.querySelector(sel); + } + + function selectedOption() { + return symbolSel.options[symbolSel.selectedIndex]; + } + + function syncDirectionLock() { + const opt = selectedOption(); + if (!opt || !opt.value) { + riskBanner.textContent = "当前风险:请选择持仓币种"; + return; + } + const dir = opt.getAttribute("data-direction") || "long"; + const rp = opt.getAttribute("data-risk-percent") || "—"; + dirInput.value = dir; + riskBanner.textContent = "当前风险:" + rp + "%(来自监控单 #" + (opt.getAttribute("data-monitor-id") || "?") + ")"; + } + + function syncFieldVisibility() { + const mode = modeSel.value; + form.querySelectorAll(".roll-field-fib").forEach(function (el) { + el.style.display = mode === "fib_618" || mode === "fib_786" ? "inline-flex" : "none"; + }); + form.querySelectorAll(".roll-field-breakout").forEach(function (el) { + el.style.display = mode === "breakout" ? "inline-flex" : "none"; + }); + const fibInputs = [qs("#roll-fib-upper"), qs("#roll-fib-lower")]; + const bpInput = qs("#roll-breakout"); + fibInputs.forEach(function (inp) { + if (inp) inp.required = mode === "fib_618" || mode === "fib_786"; + }); + if (bpInput) bpInput.required = mode === "breakout"; + resetPreview(); + } + + function resetPreview() { + previewOk = false; + submitBtn.disabled = true; + previewBox.style.display = "none"; + countdownEl.style.display = "none"; + if (countdownTimer) { + clearInterval(countdownTimer); + countdownTimer = null; + } + } + + function formPayload() { + const fd = new FormData(form); + const obj = {}; + fd.forEach(function (v, k) { + obj[k] = v; + }); + return obj; + } + + function runPreview() { + resetPreview(); + if (!symbolSel.value) { + alert("请先选择持仓币种"); + return; + } + previewBtn.disabled = true; + fetch("/strategy/roll/preview", { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify(formPayload()), + credentials: "same-origin", + }) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + previewBtn.disabled = false; + if (!data.ok) { + alert(data.msg || "预览失败"); + return; + } + const p = data.preview || {}; + lastPreviewMode = p.add_mode || modeSel.value; + previewText.innerHTML = + "" + + (p.add_mode_label || "") + + " · 约 " + + (p.add_amount_display != null ? p.add_amount_display : p.add_amount_raw) + + "
" + + "加仓参考价 " + + (p.add_price_display != null ? p.add_price_display : p.add_price) + + " · 新止损 " + + (p.new_sl_display != null ? p.new_sl_display : p.new_stop_loss) + + "
" + + "合并均价 " + + p.avg_entry_after + + " · 打到止损约 " + + p.loss_at_sl_usdt + + "U(风险预算 " + + (p.risk_budget_usdt != null ? p.risk_budget_usdt : "—") + + "U)"; + previewBox.style.display = "block"; + previewOk = true; + if (lastPreviewMode === "market") { + startCountdown(10); + } else { + submitBtn.disabled = false; + countdownEl.style.display = "none"; + } + }) + .catch(function () { + previewBtn.disabled = false; + alert("预览请求失败"); + }); + } + + function startCountdown(sec) { + let left = sec; + submitBtn.disabled = true; + countdownEl.style.display = "block"; + countdownEl.textContent = "市价加仓:" + left + " 秒后可执行(可取消刷新预览)"; + countdownTimer = setInterval(function () { + left -= 1; + if (left <= 0) { + clearInterval(countdownTimer); + countdownTimer = null; + countdownEl.textContent = "可以执行市价加仓"; + submitBtn.disabled = false; + return; + } + countdownEl.textContent = "市价加仓:" + left + " 秒后可执行"; + }, 1000); + } + + symbolSel.addEventListener("change", function () { + syncDirectionLock(); + resetPreview(); + }); + modeSel.addEventListener("change", syncFieldVisibility); + form.addEventListener("input", resetPreview); + form.addEventListener("change", function (e) { + if (e.target !== previewBtn) resetPreview(); + }); + previewBtn.addEventListener("click", runPreview); + form.addEventListener("submit", function (e) { + if (!previewOk) { + e.preventDefault(); + alert("请先点击预览"); + return; + } + if (lastPreviewMode === "market" && submitBtn.disabled) { + e.preventDefault(); + alert("请等待 10 秒确认倒计时结束"); + return; + } + const modeLabel = modeSel.options[modeSel.selectedIndex].text; + if (!confirm("确认提交「" + modeLabel + "」?")) { + e.preventDefault(); + } + }); + + syncDirectionLock(); + syncFieldVisibility(); +})(); diff --git a/strategy_db.py b/strategy_db.py index 9c5f889..19d3121 100644 --- a/strategy_db.py +++ b/strategy_db.py @@ -155,6 +155,8 @@ def init_strategy_tables(conn) -> None: "ALTER TABLE order_monitors ADD COLUMN key_signal_type TEXT", "ALTER TABLE trend_pullback_plans ADD COLUMN leg_fill_prices_json TEXT", "ALTER TABLE roll_legs ADD COLUMN stop_offset_pct REAL", + "ALTER TABLE roll_legs ADD COLUMN breakthrough_price REAL", + "ALTER TABLE roll_legs ADD COLUMN last_mark_price REAL", ): try: conn.execute(ddl) diff --git a/strategy_register.py b/strategy_register.py index 849c949..cf7f40c 100644 --- a/strategy_register.py +++ b/strategy_register.py @@ -1,15 +1,22 @@ """策略交易:Flask 路由注册(顺势加仓 + 趋势回调页)。逻辑在 strategy_*_lib。""" from __future__ import annotations +import html as html_module import os -from functools import wraps -from typing import Any, Callable, Optional +import re +from typing import Any, Optional -from flask import Flask, flash, jsonify, redirect, request, url_for +from flask import Flask, flash, jsonify, redirect, render_template, request, url_for from jinja2 import ChoiceLoader, FileSystemLoader from strategy_db import init_strategy_tables -from strategy_roll_lib import preview_roll, roll_stop_after_fill +from strategy_roll_lib import BREAKOUT_MODE, FIB_MODES, MARKET_MODE, preview_roll +from strategy_roll_monitor_lib import ( + cancel_roll_pending_leg, + count_filled_roll_legs, + count_pending_roll_legs, + sync_roll_after_external_close, +) def _dedupe_strategy_snapshots_on_startup(cfg: dict[str, Any]) -> None: @@ -68,7 +75,6 @@ def register_strategy_trading(app: Flask, cfg: dict[str, Any]) -> None: """cfg 由各市面 app 注入回调(仅 API / DB 差异)。""" login_required = cfg["login_required"] - get_db = cfg["get_db"] def _lr(f): return login_required(f) @@ -81,10 +87,11 @@ def register_strategy_trading(app: Flask, cfg: dict[str, Any]) -> None: if request.is_json: return jsonify(err) if err.get("ok"): + p = err["preview"] flash( - f"预览:加仓约 {err['preview'].get('add_amount_display', '-')} 张," - f"合并均价 {err['preview'].get('avg_entry_after', '-')}," - f"触及新止损亏损约 {err['preview'].get('loss_at_sl_usdt', '-')}U" + f"预览:约 {p.get('add_amount_display', '-')} 张," + f"合并均价 {p.get('avg_entry_after', '-')}," + f"打到止损约 {p.get('loss_at_sl_usdt', '-')}U" ) else: flash(err.get("msg") or "预览失败") @@ -103,7 +110,109 @@ def register_strategy_trading(app: Flask, cfg: dict[str, Any]) -> None: flash(msg) return redirect(url_for("strategy_trading_page")) - # 趋势回调:仍由各市面 app 注册原有路由;导航指向 /strategy/trend + @_lr + @app.route("/strategy/roll/cancel/", methods=["POST"]) + def strategy_roll_cancel_leg(leg_id: int): + conn = cfg["get_db"]() + try: + init_strategy_tables(conn) + ok, msg = cancel_roll_pending_leg(cfg, conn, leg_id) + finally: + conn.close() + if request.is_json: + return jsonify({"ok": ok, "msg": msg}) + flash(msg) + return redirect(url_for("strategy_trading_page")) + + @_lr + @app.route("/strategy/roll/docs") + def strategy_roll_docs(): + path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "顺势加仓滚仓说明.md") + if not os.path.isfile(path): + flash("滚仓说明文档不存在") + return redirect(url_for("strategy_trading_page")) + with open(path, encoding="utf-8") as f: + raw = f.read() + return render_template( + "strategy_roll_docs.html", + doc_html=_roll_doc_markdown_to_html(raw), + exchange_display=cfg.get("exchange_display") or "", + ) + + +def _roll_doc_markdown_to_html(text: str) -> str: + """轻量 Markdown → HTML(仅供滚仓说明页)。""" + lines = text.splitlines() + out: list[str] = [] + i = 0 + in_code = False + code_buf: list[str] = [] + + def flush_code() -> None: + nonlocal code_buf + if code_buf: + out.append( + "
"
+                + html_module.escape("\n".join(code_buf))
+                + "
" + ) + code_buf = [] + + def inline_md(s: str) -> str: + s = html_module.escape(s) + s = re.sub(r"`([^`]+)`", r"\1", s) + s = re.sub(r"\*\*([^*]+)\*\*", r"\1", s) + return s + + while i < len(lines): + line = lines[i] + if line.strip().startswith("```"): + if in_code: + in_code = False + flush_code() + else: + in_code = True + i += 1 + continue + if in_code: + code_buf.append(line) + i += 1 + continue + if line.startswith("# "): + out.append(f"

{inline_md(line[2:].strip())}

") + elif line.startswith("## "): + out.append(f"

{inline_md(line[3:].strip())}

") + elif line.startswith("### "): + out.append(f"

{inline_md(line[4:].strip())}

") + elif line.strip() == "---": + out.append("
") + elif line.startswith("|") and "|" in line[1:]: + rows: list[str] = [] + while i < len(lines) and lines[i].startswith("|"): + rows.append(lines[i]) + i += 1 + if len(rows) >= 2 and re.match(r"^\|[\s\-:|]+\|$", rows[1].strip()): + out.append("") + hdr = [c.strip() for c in rows[0].strip("|").split("|")] + out.append("" + "".join(f"" for c in hdr) + "") + for row in rows[2:]: + cells = [c.strip() for c in row.strip("|").split("|")] + out.append("" + "".join(f"" for c in cells) + "") + out.append("
{inline_md(c)}
{inline_md(c)}
") + continue + elif re.match(r"^[-*]\s+", line): + out.append("
    ") + while i < len(lines) and re.match(r"^[-*]\s+", lines[i]): + item = re.sub(r"^[-*]\s+", "", lines[i]) + out.append(f"
  • {inline_md(item)}
  • ") + i += 1 + out.append("
") + continue + elif line.strip(): + out.append(f"

{inline_md(line.strip())}

") + i += 1 + flush_code() + return "\n".join(out) def _row_to_dict(row) -> dict: @@ -129,8 +238,27 @@ def _count_active_trends(conn, cfg: dict) -> int: return 0 -def _roll_preview_response(cfg: dict, data: dict, json_mode: bool = False) -> dict: - """顺势加仓不占用 MAX_ACTIVE_POSITIONS 新仓名额,故不校验仓位上限冻结。""" +def _risk_from_monitor(mon: dict, cfg: dict) -> tuple[Optional[float], Optional[str]]: + try: + rp = float(mon.get("risk_percent") or cfg.get("default_risk_percent", 2)) + except (TypeError, ValueError): + return None, "监控单风险%无效" + if rp <= 0: + return None, "监控单风险%须大于0" + return rp, None + + +def _contract_size(cfg: dict, ex_sym: str) -> float: + get_cs = cfg.get("get_contract_size") + if callable(get_cs): + try: + return float(get_cs(ex_sym) or 1.0) + except Exception: + pass + return 1.0 + + +def _roll_context(cfg: dict, data: dict) -> tuple[Optional[dict], Optional[str]]: m = cfg.get("app_module") if m is not None: try: @@ -139,99 +267,153 @@ def _roll_preview_response(cfg: dict, data: dict, json_mode: bool = False) -> di mode = getattr(m, "POSITION_SIZING_MODE", None) or "risk" ok_src, src_msg = assert_open_source_allowed(mode, OPEN_SOURCE_ROLL) if not ok_src: - return {"ok": False, "msg": src_msg} + return None, src_msg except Exception: pass get_db = cfg["get_db"] symbol = cfg["normalize_symbol_input"](data.get("symbol") or "") if not symbol: - return {"ok": False, "msg": "请选择或填写币种"} + return None, "请选择或填写币种" direction = (data.get("direction") or "long").strip().lower() ex_sym = cfg["normalize_exchange_symbol"](symbol) conn = get_db() init_strategy_tables(conn) if _count_active_trends(conn, cfg) > 0: conn.close() - return {"ok": False, "msg": "存在运行中的趋势回调计划,请先结束后再滚仓"} + return None, "存在运行中的趋势回调计划,请先结束后再滚仓" mon = _get_active_monitor(conn, cfg, symbol, direction) if not mon: conn.close() - return {"ok": False, "msg": "未找到该币种同向的下单监控持仓,请先在「实盘下单」开仓"} - rg, legs_done, _is_new = _get_or_create_roll_group_meta(conn, mon) - conn.close() - pos = cfg["get_position"](ex_sym, direction) - qty = float(pos.get("contracts") or 0) - if qty <= 0: - return {"ok": False, "msg": "交易所无该方向持仓,无法滚仓"} - entry = float(pos.get("entry_price") or mon.get("trigger_price") or 0) - if entry <= 0: - return {"ok": False, "msg": "无法获取持仓均价"} - tp0 = float(mon.get("take_profit") or rg.get("initial_take_profit") or 0) - add_mode = (data.get("add_mode") or "market").strip().lower() - try: - risk_pct = float(data.get("risk_percent") or cfg.get("default_risk_percent", 2)) - except (TypeError, ValueError): - return {"ok": False, "msg": "风险%格式错误"} - stop_offset_raw = data.get("stop_offset_pct") - if stop_offset_raw in (None, ""): - stop_offset_raw = data.get("new_stop_loss") or data.get("sl") - new_sl_abs = None - stop_offset_pct = None - if data.get("stop_offset_pct") not in (None, ""): - try: - stop_offset_pct = float(data.get("stop_offset_pct")) - except (TypeError, ValueError): - return {"ok": False, "msg": "止损偏移%格式错误"} - elif data.get("new_stop_loss") not in (None, "") or data.get("sl") not in (None, ""): - try: - new_sl_abs = float(data.get("new_stop_loss") or data.get("sl")) - except (TypeError, ValueError): - return {"ok": False, "msg": "止损格式错误"} - elif stop_offset_raw not in (None, ""): - try: - new_sl_abs = float(stop_offset_raw) - except (TypeError, ValueError): - return {"ok": False, "msg": "止损格式错误"} + return None, "未找到该币种同向的下单监控持仓,请先在「实盘下单」开仓" + rg, legs_done, pending, roll_is_new = _get_or_create_roll_group_meta(conn, mon) + if pending > 0: + conn.close() + return None, "已有监控中的滚仓腿,请等待成交/失效或先删除后再提交" conn_cap = get_db() try: capital = float(cfg["get_trading_capital_usdt"](conn_cap)) finally: conn_cap.close() - live = cfg["get_price"](symbol) - fib_u = fib_l = None + risk_pct, risk_err = _risk_from_monitor(mon, cfg) + if risk_err: + conn.close() + return None, risk_err + pos = cfg["get_position"](ex_sym, direction) + qty = float(pos.get("contracts") or 0) + if qty <= 0: + conn.close() + return None, "交易所无该方向持仓,无法滚仓" + entry = float(pos.get("entry_price") or mon.get("trigger_price") or 0) + if entry <= 0: + conn.close() + return None, "无法获取持仓均价" + mark_fn = cfg.get("get_mark_price") or cfg.get("get_price") + mark = mark_fn(symbol) if callable(mark_fn) else cfg["get_price"](symbol) + ctx = { + "conn": conn, + "mon": mon, + "rg": rg, + "legs_done": legs_done, + "symbol": symbol, + "direction": direction, + "ex_sym": ex_sym, + "qty": qty, + "entry": entry, + "mark": float(mark) if mark else None, + "capital": capital, + "risk_pct": float(risk_pct), + "tp0": float(mon.get("take_profit") or rg.get("initial_take_profit") or 0), + "contract_size": _contract_size(cfg, ex_sym), + } + return ctx, None + + +def _parse_roll_form(data: dict, ctx: dict) -> tuple[Optional[dict], Optional[str]]: + add_mode = (data.get("add_mode") or MARKET_MODE).strip().lower() + raw_sl = data.get("new_stop_loss") or data.get("sl") + if raw_sl in (None, ""): + return None, "请填写新止损价" + try: + new_sl = float(raw_sl) + except (TypeError, ValueError): + return None, "止损价格式错误" + if new_sl <= 0: + return None, "止损价须大于0" + fib_u = fib_l = bp = None try: if data.get("fib_upper") not in (None, ""): fib_u = float(data.get("fib_upper")) if data.get("fib_lower") not in (None, ""): fib_l = float(data.get("fib_lower")) + if data.get("breakthrough_price") not in (None, ""): + bp = float(data.get("breakthrough_price")) except (TypeError, ValueError): - return {"ok": False, "msg": "斐波上沿/下沿格式错误"} - preview, err = preview_roll( - direction=direction, - symbol=symbol, - qty_existing=qty, - entry_existing=entry, - initial_take_profit=tp0, - add_mode=add_mode, - new_stop_loss=new_sl_abs, - stop_offset_pct=stop_offset_pct, - risk_percent=risk_pct, - capital_base_usdt=capital, - add_price=float(live) if live else None, - fib_upper=fib_u, - fib_lower=fib_l, - legs_done=legs_done, - ) + return None, "价格参数格式错误" + + add_price = ctx.get("mark") + if add_mode == MARKET_MODE: + if add_price is None or add_price <= 0: + return None, "无法获取市价快照" + elif add_mode in FIB_MODES: + if fib_u is None or fib_l is None: + return None, "斐波须填写上沿 H 与下沿 L" + elif add_mode == BREAKOUT_MODE: + if bp is None: + return None, "突破加仓须填写突破价" + add_price = ctx.get("mark") + else: + return None, "加仓方式无效" + + return { + "add_mode": add_mode, + "new_stop_loss": new_sl, + "fib_upper": fib_u, + "fib_lower": fib_l, + "breakthrough_price": bp, + "add_price": add_price, + }, None + + +def _roll_preview_response(cfg: dict, data: dict, json_mode: bool = False) -> dict: + ctx, err = _roll_context(cfg, data) if err: return {"ok": False, "msg": err} + parsed, perr = _parse_roll_form(data, ctx) + if perr: + ctx["conn"].close() + return {"ok": False, "msg": perr} + conn = ctx["conn"] + try: + preview, perr2 = preview_roll( + direction=ctx["direction"], + symbol=ctx["symbol"], + qty_existing=ctx["qty"], + entry_existing=ctx["entry"], + initial_take_profit=ctx["tp0"], + add_mode=parsed["add_mode"], + new_stop_loss=parsed["new_stop_loss"], + risk_percent=ctx["risk_pct"], + capital_base_usdt=ctx["capital"], + add_price=parsed["add_price"], + fib_upper=parsed["fib_upper"], + fib_lower=parsed["fib_lower"], + breakthrough_price=parsed["breakthrough_price"], + legs_done=ctx["legs_done"], + contract_size=ctx["contract_size"], + ) + finally: + conn.close() + if perr2: + return {"ok": False, "msg": perr2} amt_raw = float(preview["add_amount_raw"]) - amt_p = cfg["amount_to_precision"](ex_sym, amt_raw) + amt_p = cfg["amount_to_precision"](ctx["ex_sym"], amt_raw) preview["add_amount_display"] = amt_p if amt_p is not None else amt_raw + preview["risk_display"] = f"{ctx['risk_pct']:g}%≈{ctx['capital'] * ctx['risk_pct'] / 100:.2f}U" price_fmt = cfg.get("price_fmt") if callable(price_fmt): - preview["add_price_display"] = price_fmt(symbol, preview["add_price"]) - preview["new_sl_display"] = price_fmt(symbol, preview["new_stop_loss"]) - preview["tp_display"] = price_fmt(symbol, preview["initial_take_profit"]) + preview["add_price_display"] = price_fmt(ctx["symbol"], preview["add_price"]) + preview["new_sl_display"] = price_fmt(ctx["symbol"], preview["new_stop_loss"]) + preview["tp_display"] = price_fmt(ctx["symbol"], preview["initial_take_profit"]) return {"ok": True, "preview": preview} @@ -250,9 +432,8 @@ def _roll_execute(cfg: dict, data: dict) -> tuple[bool, str]: direction = preview["direction"] ex_sym = cfg["normalize_exchange_symbol"](symbol) add_mode = preview["add_mode"] - amount = cfg["amount_to_precision"](ex_sym, float(preview["add_amount_raw"])) - if amount is None or amount <= 0: - return False, "加仓张数低于交易所最小精度" + new_sl = float(preview["new_stop_loss"]) + tp0 = float(preview["initial_take_profit"]) lev_fn = cfg.get("default_leverage") if not callable(lev_fn): lev_fn = lambda _s: 5 @@ -262,123 +443,86 @@ def _roll_execute(cfg: dict, data: dict) -> tuple[bool, str]: mon = _get_active_monitor(conn, cfg, symbol, direction) if not mon: return False, "监控单已不存在" - rg, legs_done, roll_is_new = _get_or_create_roll_group_meta(conn, mon) - new_sl = float(preview["new_stop_loss"]) - stop_offset_pct = preview.get("stop_offset_pct") - tp0 = float(preview["initial_take_profit"]) - qty_before = float(preview.get("qty_existing") or 0) - entry_before = float(preview.get("entry_existing") or 0) - if add_mode == "market": + rg, legs_done, pending, roll_is_new = _get_or_create_roll_group_meta(conn, mon) + if pending > 0: + return False, "已有监控中的滚仓腿,请先删除或等待结束" + if add_mode == MARKET_MODE: + amount = cfg["amount_to_precision"](ex_sym, float(preview["add_amount_raw"])) + if amount is None or amount <= 0: + return False, "加仓张数低于交易所最小精度" order = cfg["market_add"](ex_sym, direction, amount, leverage) - fill = float(cfg.get("resolve_fill_price", lambda o, s, p: p)(order, ex_sym, preview["add_price"]) or preview["add_price"]) - status = "filled" - oid = str(order.get("id") or "") if isinstance(order, dict) else "" - if stop_offset_pct is not None and qty_before > 0 and entry_before > 0: - new_sl = roll_stop_after_fill( - direction, - qty_before, - entry_before, - float(amount), - fill, - stop_offset_pct=float(stop_offset_pct), + fill = float( + cfg.get("resolve_fill_price", lambda o, s, p: p)( + order, ex_sym, preview["add_price"] ) - px_fn = cfg.get("price_to_precision") - if callable(px_fn): - new_sl = float(px_fn(ex_sym, new_sl) or new_sl) - else: - price = cfg["price_to_precision"](ex_sym, float(preview["add_price"])) - order = cfg["limit_add"](ex_sym, direction, amount, price, leverage) + or preview["add_price"] + ) oid = str(order.get("id") or "") if isinstance(order, dict) else "" + cfg["replace_tpsl"](ex_sym, direction, new_sl, tp0, mon) conn.execute( """INSERT INTO roll_legs ( roll_group_id, leg_index, add_mode, fib_upper, fib_lower, limit_price, - amount, new_stop_loss, stop_offset_pct, exchange_order_id, status, created_at - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""", + breakthrough_price, fill_price, amount, new_stop_loss, exchange_order_id, + status, created_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( rg["id"], legs_done + 1, preview["add_mode_label"], preview.get("fib_upper"), preview.get("fib_lower"), - price, + None, + preview.get("breakthrough_price"), + fill, amount, new_sl, - stop_offset_pct, oid, - "pending", + "filled", cfg["app_now_str"](), ), ) conn.execute( - "UPDATE roll_groups SET leg_count=?, updated_at=? WHERE id=?", - (legs_done + 1, cfg["app_now_str"](), rg["id"]), + "UPDATE roll_groups SET leg_count=?, current_stop_loss=?, updated_at=? WHERE id=?", + (legs_done + 1, new_sl, cfg["app_now_str"](), rg["id"]), + ) + conn.execute( + "UPDATE order_monitors SET stop_loss=? WHERE id=?", + (new_sl, mon["id"]), ) conn.commit() - if roll_is_new: - try: - from strategy_wechat_notify import notify_roll_group_started - - notify_roll_group_started( - cfg, - group_id=int(rg["id"]), - symbol=symbol, - direction=direction, - order_monitor_id=int(mon["id"]), - initial_take_profit=tp0, - initial_stop_loss=float(mon.get("stop_loss") or new_sl), - ) - except Exception: - pass - return True, f"已挂限价加仓单 #{oid},成交后请在页面点「同步持仓并更新止损」" - cfg["replace_tpsl"](ex_sym, direction, new_sl, tp0, mon) + _maybe_notify_roll_started(cfg, rg, mon, symbol, direction, tp0, new_sl, roll_is_new=roll_is_new) + return True, f"市价加仓第 {legs_done + 1} 腿已成交,止损已更新,止盈仍为首仓" + # 程序监控:斐波 / 突破 + limit_px = None + if add_mode in FIB_MODES: + px_fn = cfg.get("price_to_precision") + limit_px = float(preview["add_price"]) + if callable(px_fn): + limit_px = float(px_fn(ex_sym, limit_px) or limit_px) + mark_fn = cfg.get("get_mark_price") or cfg.get("get_price") + last_mark = mark_fn(symbol) if callable(mark_fn) else preview["add_price"] conn.execute( """INSERT INTO roll_legs ( roll_group_id, leg_index, add_mode, fib_upper, fib_lower, limit_price, - fill_price, amount, new_stop_loss, stop_offset_pct, exchange_order_id, status, created_at - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""", + breakthrough_price, new_stop_loss, last_mark_price, status, created_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?)""", ( rg["id"], legs_done + 1, preview["add_mode_label"], preview.get("fib_upper"), preview.get("fib_lower"), - None, - fill, - amount, + limit_px, + preview.get("breakthrough_price"), new_sl, - stop_offset_pct, - oid, - "filled", + last_mark, + "pending", cfg["app_now_str"](), ), ) - conn.execute( - "UPDATE roll_groups SET leg_count=?, current_stop_loss=?, updated_at=? WHERE id=?", - (legs_done + 1, new_sl, cfg["app_now_str"](), rg["id"]), - ) - conn.execute( - "UPDATE order_monitors SET stop_loss=? WHERE id=?", - (new_sl, mon["id"]), - ) conn.commit() - try: - from strategy_wechat_notify import ( - notify_roll_group_started, - ) - - if roll_is_new: - notify_roll_group_started( - cfg, - group_id=int(rg["id"]), - symbol=symbol, - direction=direction, - order_monitor_id=int(mon["id"]), - initial_take_profit=tp0, - initial_stop_loss=new_sl, - ) - except Exception: - pass - return True, f"滚仓第 {legs_done + 1} 腿已市价成交,交易所止损已更新,止盈仍为首仓 {tp0}" + _maybe_notify_roll_started(cfg, rg, mon, symbol, direction, tp0, new_sl, roll_is_new=roll_is_new) + return True, f"已提交{preview['add_mode_label']}监控,触价后将市价加仓并更新止损" except Exception as e: fe = cfg.get("friendly_error") return False, fe(e) if callable(fe) else str(e) @@ -390,6 +534,25 @@ def _roll_execute(cfg: dict, data: dict) -> tuple[bool, str]: pass +def _maybe_notify_roll_started(cfg, rg, mon, symbol, direction, tp0, new_sl, *, roll_is_new: bool) -> None: + if not roll_is_new: + return + try: + from strategy_wechat_notify import notify_roll_group_started + + notify_roll_group_started( + cfg, + group_id=int(rg["id"]), + symbol=symbol, + direction=direction, + order_monitor_id=int(mon["id"]), + initial_take_profit=tp0, + initial_stop_loss=float(mon.get("stop_loss") or new_sl), + ) + except Exception: + pass + + def _get_active_monitor(conn, cfg: dict, symbol: str, direction: str) -> Optional[dict]: row = conn.execute( "SELECT * FROM order_monitors WHERE status='active' AND symbol=? AND direction=? ORDER BY id DESC LIMIT 1", @@ -398,14 +561,18 @@ def _get_active_monitor(conn, cfg: dict, symbol: str, direction: str) -> Optiona return _row_to_dict(row) if row else None -def _get_or_create_roll_group_meta(conn, mon: dict) -> tuple[dict, int, bool]: +def _get_or_create_roll_group_meta(conn, mon: dict) -> tuple[dict, int, int, bool]: + """返回 (roll_group, filled_legs, pending_legs, is_new_group)。""" row = conn.execute( "SELECT * FROM roll_groups WHERE order_monitor_id=? AND status='active' ORDER BY id DESC LIMIT 1", (mon["id"],), ).fetchone() if row: d = _row_to_dict(row) - return d, int(d.get("leg_count") or 0), False + gid = int(d["id"]) + filled = count_filled_roll_legs(conn, gid) + pending = count_pending_roll_legs(conn, gid) + return d, filled, pending, False now = mon.get("created_at") or "" cur = conn.execute( """INSERT INTO roll_groups ( @@ -439,7 +606,14 @@ def _get_or_create_roll_group_meta(conn, mon: dict) -> tuple[dict, int, bool]: "direction": mon.get("direction"), }, 0, + 0, True, ) +def roll_sync_after_external_close(cfg: dict, conn, symbol: str, direction: str) -> dict: + """供 hub / del_order 调用的滚仓同步入口。""" + return sync_roll_after_external_close( + cfg, conn, symbol, direction, reason="手动平仓,滚仓监控已结束" + ) + diff --git a/strategy_roll_lib.py b/strategy_roll_lib.py index ec90643..f722b02 100644 --- a/strategy_roll_lib.py +++ b/strategy_roll_lib.py @@ -1,14 +1,23 @@ -"""顺势加仓(滚仓):纯计算。人工触发;止盈锁定首仓;斐波仅算限价。""" +"""顺势加仓(滚仓):纯计算。人工触发;止盈锁定首仓;程序监控触价市价成交。""" from __future__ import annotations from typing import Any, Optional, Tuple -from fib_key_monitor_lib import calc_fib_plan, fib_ratio_from_type +from fib_key_monitor_lib import calc_fib_plan, fib_invalidate_by_mark ROLL_MAX_LEGS_LONG = 3 ROLL_MAX_LEGS_SHORT = 3 -ROLL_STOP_OFFSET_PCT_DEFAULT = 1.0 + +MARKET_MODE = "market" FIB_MODES = frozenset({"fib_618", "fib_786"}) +BREAKOUT_MODE = "breakout" + +MODE_LABELS = { + MARKET_MODE: "市价加仓", + "fib_618": "斐波0.618", + "fib_786": "斐波0.786", + BREAKOUT_MODE: "突破加仓", +} def fib_ratio_from_mode(mode: str) -> Optional[float]: @@ -20,6 +29,11 @@ def fib_ratio_from_mode(mode: str) -> Optional[float]: return None +def mode_label(mode: str) -> str: + m = (mode or MARKET_MODE).strip().lower() + return MODE_LABELS.get(m, m) + + def fib_limit_entry(direction: str, upper: float, lower: float, mode: str) -> Tuple[Optional[float], Optional[str]]: """H/L 仅用于计算限价加仓价;多:下沿=止损侧;空:上沿=止损侧。""" ratio = fib_ratio_from_mode(mode) @@ -43,49 +57,6 @@ def max_roll_legs(direction: str) -> int: return ROLL_MAX_LEGS_LONG if (direction or "long").strip().lower() == "long" else ROLL_MAX_LEGS_SHORT -def resolve_roll_stop_spec( - *, - new_stop_loss: Optional[float] = None, - stop_offset_pct: Optional[float] = None, - entry_ref: float = 0.0, -) -> tuple[str, float]: - """ - 解析滚仓止损输入。 - - stop_offset_pct:相对合并均价的偏移%,如 1 表示 1%(多:均价下方;空:均价上方)。 - - new_stop_loss:兼容旧版绝对止损价;若数值很小(如 1.0)且相对均价过低,视为偏移%。 - """ - if stop_offset_pct is not None: - try: - pct = float(stop_offset_pct) - if pct > 0: - return "offset", pct - except (TypeError, ValueError): - pass - if new_stop_loss is not None: - try: - sl = float(new_stop_loss) - if sl > 0: - ref = float(entry_ref or 0) - if ref > 0 and sl <= min(30.0, ref * 0.25): - return "offset", sl - return "absolute", sl - except (TypeError, ValueError): - pass - return "offset", ROLL_STOP_OFFSET_PCT_DEFAULT - - -def unified_stop_from_avg(direction: str, avg: float, offset_pct: float) -> float: - """合并均价 ± offset% 作为新统一止损(非保本)。""" - avg_f = float(avg) - pct = float(offset_pct) / 100.0 - if avg_f <= 0 or pct <= 0: - return 0.0 - direction = (direction or "long").strip().lower() - if direction == "short": - return avg_f * (1.0 + pct) - return avg_f * (1.0 - pct) - - def avg_entry_after_add( qty_existing: float, entry_existing: float, @@ -102,44 +73,8 @@ def avg_entry_after_add( return (q1 * e1 + q2 * e2) / total -def solve_add_amount_for_avg_stop_offset( - direction: str, - qty_existing: float, - entry_existing: float, - add_price: float, - offset_pct: float, - risk_budget_usdt: float, -) -> Tuple[Optional[float], Optional[str]]: - """ - 合并后止损 = 合并均价 ± offset%,且触及止损时总亏损 ≈ risk_budget。 - loss = offset% × (Q1·E1 + Q2·E2) => Q2 = (B/p − Q1·E1) / E2 - """ - try: - q1 = float(qty_existing) - e1 = float(entry_existing) - e2 = float(add_price) - b = float(risk_budget_usdt) - p = float(offset_pct) / 100.0 - except (TypeError, ValueError): - return None, "参数格式错误" - if q1 <= 0 or e1 <= 0 or e2 <= 0 or b <= 0: - return None, "持仓或风险预算无效" - if p <= 0 or p >= 1: - return None, "止损偏移%须大于 0 且小于 100" - direction = (direction or "long").strip().lower() - need_notional = b / p - q2 = (need_notional - q1 * e1) / e2 - if q2 <= 0: - return None, "按当前偏移%与总风险%,无需加仓或无法再加(已满足风险上限)" - new_avg = avg_entry_after_add(q1, e1, q2, e2) - sl = unified_stop_from_avg(direction, new_avg, offset_pct) - if direction == "short": - if sl <= e2: - return None, "做空:合并后止损须高于加仓价(请减小偏移%或风险%)" - else: - if sl >= e2: - return None, "做多:合并后止损须低于加仓价(请减小偏移%或风险%)" - return q2, None +def calc_risk_budget_usdt(capital_base_usdt: float, risk_percent: float) -> float: + return float(capital_base_usdt) * (float(risk_percent) / 100.0) def solve_add_amount_for_total_risk( @@ -149,11 +84,12 @@ def solve_add_amount_for_total_risk( add_price: float, new_stop: float, risk_budget_usdt: float, + contract_size: float = 1.0, ) -> Tuple[Optional[float], Optional[str]]: """ - 已知合并后若触及 new_stop 总亏损=risk_budget,反推本次加仓张数 Q2。 - long: (avg - SL) * (Q1+Q2) = B => Q2 = (B - Q1*(E1-SL)) / (E2-SL) - short: (SL - avg) * (Q1+Q2) = B => Q2 = (B - Q1*(SL-E1)) / (SL-E2) + 合并持仓打到 new_stop 时总亏损 ≈ risk_budget(方案 C)。 + long: (avg - SL) * (Q1+Q2) * cs = B => Q2 = (B/cs - Q1*(E1-SL)) / (E2-SL) + short: (SL - avg) * (Q1+Q2) * cs = B => Q2 = (B/cs - Q1*(SL-E1)) / (SL-E2) """ try: q1 = float(qty_existing) @@ -161,27 +97,196 @@ def solve_add_amount_for_total_risk( e2 = float(add_price) sl = float(new_stop) b = float(risk_budget_usdt) + cs = float(contract_size) if contract_size else 1.0 except (TypeError, ValueError): return None, "参数格式错误" - if q1 <= 0 or e1 <= 0 or e2 <= 0 or b <= 0: + if q1 <= 0 or e1 <= 0 or e2 <= 0 or b <= 0 or cs <= 0: return None, "持仓或风险预算无效" direction = (direction or "long").strip().lower() if direction == "short": denom = sl - e2 - numer = b - q1 * (sl - e1) + numer = b / cs - q1 * (sl - e1) if denom <= 0: - return None, "做空:新止损须高于限价加仓价" + return None, "做空:新止损须高于加仓价" else: denom = e2 - sl - numer = b - q1 * (e1 - sl) + numer = b / cs - q1 * (e1 - sl) if denom <= 0: - return None, "做多:新止损须低于限价/市价加仓价" + return None, "做多:新止损须低于加仓价" q2 = numer / denom if q2 <= 0: - return None, "按当前新止损与总风险%,无需加仓或无法再加(已满足风险上限)" + return None, "按当前新止损与风险预算,无需加仓或无法再加(已满足风险上限)" return q2, None +def loss_at_stop_usdt( + direction: str, + avg: float, + qty: float, + stop: float, + contract_size: float = 1.0, +) -> float: + cs = float(contract_size or 1.0) + direction = (direction or "long").strip().lower() + if direction == "short": + return (float(stop) - float(avg)) * float(qty) * cs + return (float(avg) - float(stop)) * float(qty) * cs + + +def reward_at_tp_usdt( + direction: str, + avg: float, + take_profit: float, + qty: float, + contract_size: float = 1.0, +) -> float: + cs = float(contract_size or 1.0) + direction = (direction or "long").strip().lower() + if direction == "short": + return (float(avg) - float(take_profit)) * float(qty) * cs + return (float(take_profit) - float(avg)) * float(qty) * cs + + +def roll_fib_trigger_crossed( + direction: str, + prev_mark: Optional[float], + mark: float, + limit_price: float, +) -> bool: + """斐波:多=向下穿越限价;空=向上穿越限价。""" + try: + m = float(mark) + lv = float(limit_price) + pm = float(prev_mark) if prev_mark is not None else None + except (TypeError, ValueError): + return False + direction = (direction or "long").strip().lower() + if direction == "long": + if pm is None: + return m <= lv + return pm > lv and m <= lv + if pm is None: + return m >= lv + return pm < lv and m >= lv + + +def roll_breakout_trigger_crossed( + direction: str, + prev_mark: Optional[float], + mark: float, + breakthrough_price: float, +) -> bool: + """突破:多=向上穿越突破价;空=向下穿越突破价。""" + try: + m = float(mark) + bp = float(breakthrough_price) + pm = float(prev_mark) if prev_mark is not None else None + except (TypeError, ValueError): + return False + direction = (direction or "long").strip().lower() + if direction == "long": + if pm is None: + return m > bp + return pm <= bp and m > bp + if pm is None: + return m < bp + return pm >= bp and m < bp + + +def roll_fib_invalidate(direction: str, mark: float, upper: float, lower: float) -> bool: + """斐波 pending 失效:止盈侧突破(多 mark>=H;空 mark<=L)。""" + return fib_invalidate_by_mark(direction, mark, upper, lower) + + +def roll_breakout_invalidate(direction: str, mark: float, stop_loss: float) -> bool: + """突破 pending 失效:未到突破价先触达止损侧(多 mark<=S;空 mark>=S)。""" + try: + m = float(mark) + sl = float(stop_loss) + except (TypeError, ValueError): + return False + direction = (direction or "long").strip().lower() + if direction == "long": + return m <= sl + return m >= sl + + +def validate_roll_geometry( + direction: str, + add_mode: str, + *, + new_stop_loss: float, + add_price: Optional[float] = None, + fib_upper: Optional[float] = None, + fib_lower: Optional[float] = None, + breakthrough_price: Optional[float] = None, + entry_existing: float = 0.0, + initial_take_profit: float = 0.0, + mark_price: Optional[float] = None, +) -> Optional[str]: + direction = (direction or "long").strip().lower() + mode = (add_mode or MARKET_MODE).strip().lower() + try: + sl = float(new_stop_loss) + tp = float(initial_take_profit) + e1 = float(entry_existing or 0) + except (TypeError, ValueError): + return "止损/止盈格式错误" + if sl <= 0 or tp <= 0: + return "止损与首仓止盈须大于0" + if direction == "long": + if e1 > 0 and tp <= e1: + return "做多:首仓止盈须高于当前持仓均价" + else: + if e1 > 0 and tp >= e1: + return "做空:首仓止盈须低于当前持仓均价" + + if mode == MARKET_MODE: + if add_price is None or float(add_price) <= 0: + return "市价加仓需要有效参考价" + entry_add = float(add_price) + elif mode in FIB_MODES: + if fib_upper is None or fib_lower is None: + return "斐波须填写上沿 H 与下沿 L" + entry_add, err = fib_limit_entry(direction, float(fib_upper), float(fib_lower), mode) + if err: + return err + if entry_add is None or entry_add <= 0: + return "无法计算斐波限价" + elif mode == BREAKOUT_MODE: + if breakthrough_price is None: + return "突破加仓须填写突破价" + try: + bp = float(breakthrough_price) + except (TypeError, ValueError): + return "突破价格式错误" + if bp <= 0: + return "突破价须大于0" + entry_add = bp + if direction == "long": + if sl >= bp: + return "做多:止损须低于突破价" + if mark_price is not None and float(mark_price) <= bp: + return "做多:当前价须高于突破价(等待向上突破)" + else: + if sl <= bp: + return "做空:止损须高于突破价" + if mark_price is not None and float(mark_price) >= bp: + return "做空:当前价须低于突破价(等待向下突破)" + else: + return "加仓方式无效" + + if mode != BREAKOUT_MODE: + entry_add = float(entry_add) # type: ignore[arg-type] + if direction == "long": + if sl >= entry_add: + return "做多:新止损须低于加仓价" + else: + if sl <= entry_add: + return "做空:新止损须高于加仓价" + return None + + def preview_roll( *, direction: str, @@ -191,92 +296,78 @@ def preview_roll( initial_take_profit: float, add_mode: str, new_stop_loss: Optional[float] = None, - stop_offset_pct: Optional[float] = None, risk_percent: float, capital_base_usdt: float, add_price: Optional[float] = None, fib_upper: Optional[float] = None, fib_lower: Optional[float] = None, + breakthrough_price: Optional[float] = None, legs_done: int = 0, + contract_size: float = 1.0, ) -> Tuple[Optional[dict[str, Any]], Optional[str]]: direction = (direction or "long").strip().lower() if legs_done >= max_roll_legs(direction): return None, f"{'做多' if direction == 'long' else '做空'}滚仓已达 {max_roll_legs(direction)} 次上限" - mode = (add_mode or "market").strip().lower() - if mode == "market": - if add_price is None or add_price <= 0: - return None, "市价加仓需要有效参考价" - entry_add = float(add_price) - mode_label = "市价" - elif mode in FIB_MODES: - if fib_upper is None or fib_lower is None: - return None, "斐波限价须填写上沿与下沿" - entry_add, err = fib_limit_entry(direction, float(fib_upper), float(fib_lower), mode) - if err: - return None, err - mode_label = "斐波0.618" if "618" in mode else "斐波0.786" - else: - return None, "加仓方式无效" + mode = (add_mode or MARKET_MODE).strip().lower() + if new_stop_loss is None: + return None, "请填写新止损价" try: - tp = float(initial_take_profit) + sl = float(new_stop_loss) except (TypeError, ValueError): - return None, "止盈格式错误" - if tp <= 0: - return None, "首仓止盈须大于0" - stop_mode, stop_val = resolve_roll_stop_spec( - new_stop_loss=new_stop_loss, - stop_offset_pct=stop_offset_pct, - entry_ref=entry_existing, + return None, "止损价格式错误" + if sl <= 0: + return None, "止损须大于0" + + geom_err = validate_roll_geometry( + direction, + mode, + new_stop_loss=sl, + add_price=add_price, + fib_upper=fib_upper, + fib_lower=fib_lower, + breakthrough_price=breakthrough_price, + entry_existing=entry_existing, + initial_take_profit=initial_take_profit, + mark_price=add_price if mode == BREAKOUT_MODE else add_price, ) - if direction == "long": - if tp <= entry_existing: - return None, "做多:首仓止盈须高于当前持仓均价参考" + if geom_err: + return None, geom_err + + if mode == MARKET_MODE: + entry_add = float(add_price) # validated + elif mode in FIB_MODES: + entry_add, _ = fib_limit_entry(direction, float(fib_upper), float(fib_lower), mode) + entry_add = float(entry_add or 0) else: - if tp >= entry_existing: - return None, "做空:首仓止盈须低于当前持仓均价参考" - risk_budget = float(capital_base_usdt) * (float(risk_percent) / 100.0) - offset_pct: Optional[float] = None - if stop_mode == "offset": - offset_pct = float(stop_val) - q2_raw, err = solve_add_amount_for_avg_stop_offset( - direction, qty_existing, entry_existing, entry_add, offset_pct, risk_budget - ) - else: - sl = float(stop_val) - if sl <= 0: - return None, "止损须大于0" - if direction == "long": - if sl >= entry_add: - return None, "做多:新止损须低于加仓价" - else: - if sl <= entry_add: - return None, "做空:新止损须高于加仓价" - q2_raw, err = solve_add_amount_for_total_risk( - direction, qty_existing, entry_existing, entry_add, sl, risk_budget - ) + entry_add = float(breakthrough_price or 0) + + risk_budget = calc_risk_budget_usdt(capital_base_usdt, risk_percent) + q2_raw, err = solve_add_amount_for_total_risk( + direction, + qty_existing, + entry_existing, + entry_add, + sl, + risk_budget, + contract_size, + ) if err: return None, err q2 = float(q2_raw) new_qty = qty_existing + q2 new_avg = avg_entry_after_add(qty_existing, entry_existing, q2, entry_add) - if stop_mode == "offset": - sl = unified_stop_from_avg(direction, new_avg, offset_pct) - if direction == "long": - loss_at_sl = (new_avg - sl) * new_qty - reward_at_tp = (tp - new_avg) * new_qty - else: - loss_at_sl = (sl - new_avg) * new_qty - reward_at_tp = (new_avg - tp) * new_qty + cs = float(contract_size or 1.0) + loss_sl = loss_at_stop_usdt(direction, new_avg, new_qty, sl, cs) + reward_tp = reward_at_tp_usdt(direction, new_avg, initial_take_profit, new_qty, cs) return { "symbol": symbol, "direction": direction, "add_mode": mode, - "add_mode_label": mode_label, + "add_mode_label": mode_label(mode), "add_price": round(entry_add, 10), "new_stop_loss": round(sl, 10), - "stop_offset_pct": offset_pct, - "stop_mode": stop_mode, - "initial_take_profit": tp, + "breakthrough_price": float(breakthrough_price) if breakthrough_price not in (None, "") else None, + "initial_take_profit": float(initial_take_profit), "risk_percent": float(risk_percent), "risk_budget_usdt": round(risk_budget, 4), "add_amount_raw": q2, @@ -284,27 +375,11 @@ def preview_roll( "entry_existing": float(entry_existing), "qty_after": new_qty, "avg_entry_after": round(new_avg, 10), - "loss_at_sl_usdt": round(loss_at_sl, 4), - "reward_at_tp_usdt": round(reward_at_tp, 4), + "loss_at_sl_usdt": round(loss_sl, 4), + "reward_at_tp_usdt": round(reward_tp, 4), "legs_done": int(legs_done), "leg_index_next": int(legs_done) + 1, "fib_upper": fib_upper, "fib_lower": fib_lower, + "contract_size": cs, }, None - - -def roll_stop_after_fill( - direction: str, - qty_before: float, - entry_before: float, - add_qty: float, - fill_price: float, - *, - stop_offset_pct: Optional[float] = None, - absolute_stop: Optional[float] = None, -) -> float: - """成交后按合并均价重算统一止损(偏移%模式)或沿用绝对止损。""" - if stop_offset_pct is not None and float(stop_offset_pct) > 0: - avg = avg_entry_after_add(qty_before, entry_before, add_qty, fill_price) - return unified_stop_from_avg(direction, avg, float(stop_offset_pct)) - return float(absolute_stop or 0) diff --git a/strategy_roll_monitor_lib.py b/strategy_roll_monitor_lib.py index 9aa09b6..b502ddb 100644 --- a/strategy_roll_monitor_lib.py +++ b/strategy_roll_monitor_lib.py @@ -1,17 +1,29 @@ -"""滚仓挂单监控:斐波限价止盈侧突破撤单、成交同步、活跃组结案(各所共用)。""" +"""滚仓程序监控:斐波/突破触价市价成交、失效、外部平仓同步(各所共用)。""" from __future__ import annotations from typing import Any, Optional -from fib_key_monitor_lib import fib_invalidate_by_mark -from strategy_roll_lib import unified_stop_from_avg +from strategy_roll_lib import ( + BREAKOUT_MODE, + FIB_MODES, + MARKET_MODE, + mode_label, + roll_breakout_invalidate, + roll_breakout_trigger_crossed, + roll_fib_invalidate, + roll_fib_trigger_crossed, + calc_risk_budget_usdt, + max_roll_legs, + preview_roll, + solve_add_amount_for_total_risk, +) from strategy_db import init_strategy_tables ROLL_LEG_STATUS_LABELS = { - "pending": "挂单中", + "pending": "监控中", "filled": "已成交", - "cancelled": "已撤销", - "invalidated": "止盈侧突破失效", + "cancelled": "已删除", + "invalidated": "已失效", } @@ -40,6 +52,97 @@ def check_roll_monitors(cfg: dict[str, Any]) -> None: pass +def sync_roll_after_external_close( + cfg: dict, conn, symbol: str, direction: str, *, reason: str = "持仓已平" +) -> dict[str, Any]: + """中控/实例手动平仓后:取消 pending 腿并关闭 active 滚仓组(保留 filled 历史)。""" + norm = cfg.get("normalize_symbol_input") + sym = norm(symbol) if callable(norm) else (symbol or "").strip() + if not sym: + return {"ok": False, "msg": "symbol 无效", "closed_groups": 0, "cancelled_legs": 0} + direction = (direction or "long").strip().lower() + init_strategy_tables(conn) + rows = conn.execute( + """SELECT g.* FROM roll_groups g + WHERE g.status='active' AND g.symbol=? AND g.direction=?""", + (sym, direction), + ).fetchall() + closed = cancelled = 0 + for row in rows: + g = _row_dict(row) + cancelled += _cancel_pending_legs_for_group(conn, cfg, g, status="cancelled") + cur = conn.execute( + "UPDATE roll_groups SET status='closed', updated_at=? WHERE id=? AND status='active'", + (_now(cfg), int(g["id"])), + ) + if getattr(cur, "rowcount", 0): + closed += 1 + try: + from strategy_wechat_notify import notify_roll_group_ended + + notify_roll_group_ended( + cfg, + group_id=int(g["id"]), + symbol=sym, + direction=direction, + reason=reason, + leg_count=int(g.get("leg_count") or 0), + ) + except Exception: + pass + try: + from strategy_snapshot_lib import save_roll_group_snapshot + + save_roll_group_snapshot(cfg, conn, g, result_label="结束") + except Exception: + pass + return { + "ok": True, + "symbol": sym, + "direction": direction, + "closed_groups": closed, + "cancelled_legs": cancelled, + } + + +def cancel_roll_pending_leg(cfg: dict, conn, leg_id: int) -> tuple[bool, str]: + """用户删除 pending 滚仓腿(不可修改,仅删除)。""" + init_strategy_tables(conn) + row = conn.execute( + "SELECT l.*, g.symbol, g.direction, g.status AS group_status FROM roll_legs l " + "INNER JOIN roll_groups g ON g.id = l.roll_group_id WHERE l.id=?", + (int(leg_id),), + ).fetchone() + if not row: + return False, "滚仓腿不存在" + leg = _row_dict(row) + if (leg.get("status") or "").strip().lower() != "pending": + return False, "仅监控中的腿可删除" + _cancel_roll_leg_order(cfg, {"symbol": leg.get("symbol"), "exchange_symbol": leg.get("exchange_symbol")}, leg) + conn.execute( + "UPDATE roll_legs SET status='cancelled' WHERE id=? AND status='pending'", + (int(leg_id),), + ) + conn.commit() + return True, "已删除滚仓监控" + + +def count_filled_roll_legs(conn, roll_group_id: int) -> int: + row = conn.execute( + "SELECT COUNT(*) FROM roll_legs WHERE roll_group_id=? AND status='filled'", + (int(roll_group_id),), + ).fetchone() + return int(row[0] if row else 0) + + +def count_pending_roll_legs(conn, roll_group_id: int) -> int: + row = conn.execute( + "SELECT COUNT(*) FROM roll_legs WHERE roll_group_id=? AND status='pending'", + (int(roll_group_id),), + ).fetchone() + return int(row[0] if row else 0) + + def _row_dict(row) -> dict: if row is None: return {} @@ -54,25 +157,26 @@ def _now(cfg: dict) -> str: return fn() if callable(fn) else "" -def _close_roll_group( - conn, - cfg: dict, - group: dict, - *, - cancel_pending: bool = True, -) -> None: +def _cancel_pending_legs_for_group(conn, cfg: dict, group: dict, *, status: str = "cancelled") -> int: gid = int(group["id"]) - if cancel_pending: - for leg in conn.execute( - "SELECT * FROM roll_legs WHERE roll_group_id=? AND status='pending'", - (gid,), - ).fetchall(): - ld = _row_dict(leg) - _cancel_roll_leg_order(cfg, group, ld) - conn.execute( - "UPDATE roll_legs SET status='cancelled' WHERE id=? AND status='pending'", - (ld["id"],), - ) + n = 0 + for leg in conn.execute( + "SELECT * FROM roll_legs WHERE roll_group_id=? AND status='pending'", + (gid,), + ).fetchall(): + ld = _row_dict(leg) + _cancel_roll_leg_order(cfg, group, ld) + conn.execute( + "UPDATE roll_legs SET status=? WHERE id=? AND status='pending'", + (status, ld["id"]), + ) + n += 1 + return n + + +def _close_roll_group(conn, cfg: dict, group: dict, *, reason: str = "下单监控已结案或交易所无同向持仓") -> None: + gid = int(group["id"]) + _cancel_pending_legs_for_group(conn, cfg, group, status="cancelled") cur = conn.execute( "UPDATE roll_groups SET status='closed', updated_at=? WHERE id=? AND status='active'", (_now(cfg), gid), @@ -81,7 +185,6 @@ def _close_roll_group( try: from strategy_wechat_notify import notify_roll_group_ended - reason = "下单监控已结案或交易所无同向持仓" notify_roll_group_ended( cfg, group_id=gid, @@ -116,7 +219,7 @@ def _reconcile_roll_groups(conn, cfg: dict) -> None: pos = cfg["get_position"](ex_sym, direction) qty = float(pos.get("contracts") or 0) if not mon_ok or qty <= 0: - _close_roll_group(conn, cfg, g, cancel_pending=True) + _close_roll_group(conn, cfg, g) def _cancel_roll_leg_order(cfg: dict, group: dict, leg: dict) -> None: @@ -133,10 +236,35 @@ def _cancel_roll_leg_order(cfg: dict, group: dict, leg: dict) -> None: pass +def _contract_size(cfg: dict, ex_sym: str) -> float: + get_cs = cfg.get("get_contract_size") + if callable(get_cs): + try: + return float(get_cs(ex_sym) or 1.0) + except Exception: + pass + return 1.0 + + +def _resolve_add_mode(leg: dict) -> str: + raw = (leg.get("add_mode") or "").strip().lower() + if raw in (MARKET_MODE, "market", "市价", "市价加仓"): + return MARKET_MODE + if "786" in raw or raw == "fib_786": + return "fib_786" + if "618" in raw or raw == "fib_618": + return "fib_618" + if raw in (BREAKOUT_MODE, "突破", "突破加仓"): + return BREAKOUT_MODE + if raw.startswith("fib"): + return raw.replace(".", "_").replace("0.", "0") + return raw or MARKET_MODE + + def _check_pending_roll_legs(conn, cfg: dict) -> None: rows = conn.execute( """SELECT l.*, g.symbol, g.exchange_symbol, g.direction, g.initial_take_profit, - g.order_monitor_id + g.order_monitor_id, g.risk_percent, g.leg_count FROM roll_legs l INNER JOIN roll_groups g ON g.id = l.roll_group_id AND g.status='active' WHERE l.status='pending'""" @@ -150,6 +278,8 @@ def _check_pending_roll_legs(conn, cfg: dict) -> None: "direction": leg["direction"], "initial_take_profit": leg["initial_take_profit"], "order_monitor_id": leg["order_monitor_id"], + "risk_percent": leg.get("risk_percent"), + "leg_count": leg.get("leg_count"), } _process_pending_roll_leg(conn, cfg, group, leg) @@ -158,56 +288,51 @@ def _process_pending_roll_leg(conn, cfg: dict, group: dict, leg: dict) -> None: symbol = group.get("symbol") or "" direction = (group.get("direction") or "long").strip().lower() ex_sym = group.get("exchange_symbol") or cfg["normalize_exchange_symbol"](symbol) - oid = (leg.get("exchange_order_id") or "").strip() mark_fn = cfg.get("get_mark_price") or cfg.get("get_price") mark = mark_fn(symbol) if callable(mark_fn) else None if mark is None: return + mark_f = float(mark) + prev_mark = leg.get("last_mark_price") + try: + prev_f = float(prev_mark) if prev_mark not in (None, "") else None + except (TypeError, ValueError): + prev_f = None - order_status_fn = cfg.get("limit_order_status") - order_st = order_status_fn(ex_sym, oid) if callable(order_status_fn) and oid else "missing" - + mode = _resolve_add_mode(leg) + sl = float(leg.get("new_stop_loss") or 0) fib_u, fib_l = leg.get("fib_upper"), leg.get("fib_lower") - has_fib = fib_u is not None and fib_l is not None + bp = leg.get("breakthrough_price") - if order_st == "filled": - _finalize_roll_leg_fill(conn, cfg, group, leg, ex_sym, direction, float(mark)) - return + if mode in FIB_MODES and fib_u is not None and fib_l is not None: + if roll_fib_invalidate(direction, mark_f, float(fib_u), float(fib_l)): + _invalidate_roll_leg(conn, cfg, group, leg, mark_f, reason="止盈侧突破") + return + elif mode == BREAKOUT_MODE and sl > 0: + if roll_breakout_invalidate(direction, mark_f, sl): + _invalidate_roll_leg(conn, cfg, group, leg, mark_f, reason="止损侧突破") + return - if has_fib and fib_invalidate_by_mark(direction, mark, fib_u, fib_l): - if order_st == "open": - _cancel_roll_leg_order(cfg, group, leg) - _invalidate_roll_leg(conn, cfg, group, leg, float(mark)) - return + triggered = False + if mode in FIB_MODES: + lp = leg.get("limit_price") + if lp is not None and roll_fib_trigger_crossed(direction, prev_f, mark_f, float(lp)): + triggered = True + elif mode == BREAKOUT_MODE and bp is not None: + if roll_breakout_trigger_crossed(direction, prev_f, mark_f, float(bp)): + triggered = True - if order_st in ("canceled", "missing", "unknown") and has_fib: - if fib_invalidate_by_mark(direction, mark, fib_u, fib_l): - _invalidate_roll_leg(conn, cfg, group, leg, float(mark)) - - -def _invalidate_roll_leg( - conn, cfg: dict, group: dict, leg: dict, mark: float -) -> None: - leg_id = int(leg["id"]) - gid = int(group["id"]) - cur = conn.execute( - "SELECT status FROM roll_legs WHERE id=?", (leg_id,) - ).fetchone() - if not cur or (cur[0] or "").strip().lower() == "invalidated": - return conn.execute( - "UPDATE roll_legs SET status='invalidated' WHERE id=? AND status='pending'", - (leg_id,), + "UPDATE roll_legs SET last_mark_price=? WHERE id=? AND status='pending'", + (mark_f, int(leg["id"])), ) - conn.execute( - """UPDATE roll_groups SET leg_count = CASE WHEN leg_count > 0 THEN leg_count - 1 ELSE 0 END, - updated_at=? WHERE id=?""", - (_now(cfg), gid), - ) - _send_roll_invalidate_wechat(cfg, group, leg, mark) + + if triggered: + _execute_pending_roll_leg(conn, cfg, group, leg, ex_sym, direction, mark_f) + return -def _finalize_roll_leg_fill( +def _execute_pending_roll_leg( conn, cfg: dict, group: dict, @@ -217,94 +342,161 @@ def _finalize_roll_leg_fill( mark: float, ) -> None: leg_id = int(leg["id"]) - gid = int(group["id"]) - new_sl = float(leg.get("new_stop_loss") or 0) - stop_offset_pct = leg.get("stop_offset_pct") - tp0 = float(group.get("initial_take_profit") or 0) - fill_px = float(leg.get("limit_price") or mark) - add_qty = float(leg.get("amount") or 0) - if stop_offset_pct not in (None, ""): - try: - offset_pct = float(stop_offset_pct) - except (TypeError, ValueError): - offset_pct = 0.0 - if offset_pct > 0: - pos = cfg["get_position"](ex_sym, direction) or {} - avg = float(pos.get("entry_price") or 0) - if avg <= 0 and add_qty > 0: - avg = fill_px - if avg > 0: - new_sl = unified_stop_from_avg(direction, avg, offset_pct) - px_fn = cfg.get("price_to_precision") - if callable(px_fn): - try: - new_sl = float(px_fn(ex_sym, new_sl) or new_sl) - except Exception: - pass - conn.execute( - "UPDATE roll_legs SET status='filled', fill_price=?, new_stop_loss=? WHERE id=? AND status='pending'", - (fill_px, new_sl, leg_id), - ) - if new_sl > 0: - conn.execute( - "UPDATE roll_groups SET current_stop_loss=?, updated_at=? WHERE id=?", - (new_sl, _now(cfg), gid), - ) + gid = int(group["roll_group_id"]) if "roll_group_id" in leg else int(group["id"]) mon_id = group.get("order_monitor_id") - if mon_id and new_sl > 0: - conn.execute( - "UPDATE order_monitors SET stop_loss=? WHERE id=? AND status='active'", - (new_sl, mon_id), + mon = None + if mon_id: + row = conn.execute("SELECT * FROM order_monitors WHERE id=?", (mon_id,)).fetchone() + mon = _row_dict(row) if row else None + if not mon or (mon.get("status") or "").strip().lower() != "active": + _invalidate_roll_leg(conn, cfg, group, leg, mark, reason="监控单已失效") + return + + pos = cfg["get_position"](ex_sym, direction) or {} + qty = float(pos.get("contracts") or 0) + entry = float(pos.get("entry_price") or mon.get("trigger_price") or 0) + if qty <= 0 or entry <= 0: + _invalidate_roll_leg(conn, cfg, group, leg, mark, reason="无持仓") + return + + filled = count_filled_roll_legs(conn, gid) + if filled >= max_roll_legs(direction): + _invalidate_roll_leg(conn, cfg, group, leg, mark, reason="滚仓次数已满") + return + + try: + risk_pct = float(mon.get("risk_percent") or group.get("risk_percent") or 2) + except (TypeError, ValueError): + risk_pct = 2.0 + conn_cap = cfg["get_db"]() + try: + capital = float(cfg["get_trading_capital_usdt"](conn_cap)) + finally: + conn_cap.close() + + cs = _contract_size(cfg, ex_sym) + sl = float(leg.get("new_stop_loss") or 0) + tp0 = float(group.get("initial_take_profit") or mon.get("take_profit") or 0) + mode = _resolve_add_mode(leg) + + q2_raw, err = solve_add_amount_for_total_risk( + direction, qty, entry, mark, sl, calc_risk_budget_usdt(capital, risk_pct), cs + ) + if err or q2_raw is None or float(q2_raw) <= 0: + _invalidate_roll_leg(conn, cfg, group, leg, mark, reason=err or "无法计算加仓张数") + return + + amount = cfg["amount_to_precision"](ex_sym, float(q2_raw)) + if amount is None or float(amount) <= 0: + _invalidate_roll_leg(conn, cfg, group, leg, mark, reason="加仓张数低于交易所最小精度") + return + + lev_fn = cfg.get("default_leverage") + if not callable(lev_fn): + lev_fn = lambda _s: 5 + leverage = int(lev_fn(group.get("symbol") or "")) + + try: + order = cfg["market_add"](ex_sym, direction, float(amount), leverage) + fill = float( + cfg.get("resolve_fill_price", lambda o, s, p: p)(order, ex_sym, mark) or mark ) - replace = cfg.get("replace_tpsl") - if callable(replace) and new_sl > 0 and tp0 > 0: - mon = None - if mon_id: - row = conn.execute( - "SELECT * FROM order_monitors WHERE id=?", (mon_id,) - ).fetchone() - mon = _row_dict(row) if row else None - try: - replace(ex_sym, direction, new_sl, tp0, mon) - except Exception: - pass + except Exception as e: + fe = cfg.get("friendly_error") + msg = fe(e) if callable(fe) else str(e) + _notify_roll_fail(cfg, group, leg, mark, msg) + return + + oid = str(order.get("id") or "") if isinstance(order, dict) else "" + cfg["replace_tpsl"](ex_sym, direction, sl, tp0, mon) + conn.execute( + """UPDATE roll_legs SET status='filled', fill_price=?, amount=?, exchange_order_id=?, + new_stop_loss=? WHERE id=? AND status='pending'""", + (fill, float(amount), oid, sl, leg_id), + ) + conn.execute( + "UPDATE roll_groups SET leg_count=?, current_stop_loss=?, updated_at=? WHERE id=?", + (filled + 1, sl, _now(cfg), gid), + ) + conn.execute( + "UPDATE order_monitors SET stop_loss=? WHERE id=? AND status='active'", + (sl, mon["id"]), + ) + notify = cfg.get("send_wechat") if callable(notify): sym = group.get("symbol") or "" - mode = leg.get("add_mode") or "限价" + mode_lbl = leg.get("add_mode") or mode_label(mode) fmt = cfg.get("format_price") - px_txt = fmt(sym, fill_px) if callable(fmt) else str(fill_px) - sl_txt = fmt(sym, new_sl) if callable(fmt) else str(new_sl) + px_txt = fmt(sym, fill) if callable(fmt) else str(fill) + sl_txt = fmt(sym, sl) if callable(fmt) else str(sl) acct = _wechat_account(cfg) dir_txt = _wechat_dir(cfg, direction) notify( - f"# ✅ {sym} 滚仓限价已成交\n" + f"# ✅ {sym} 滚仓触价成交\n" f"**账户:{acct}**\n" - f"- 方式:{mode}|{dir_txt}\n" - f"- 成交价:{px_txt}|新止损:{sl_txt}\n" - f"- 交易所止损已尝试同步(止盈仍为首仓)\n" + f"- 方式:{mode_lbl}|{dir_txt}\n" + f"- 成交价:{px_txt}|张数:{amount}\n" + f"- 新止损:{sl_txt}(止盈仍为首仓)\n" ) +def _invalidate_roll_leg( + conn, + cfg: dict, + group: dict, + leg: dict, + mark: float, + *, + reason: str = "", +) -> None: + leg_id = int(leg["id"]) + cur = conn.execute("SELECT status FROM roll_legs WHERE id=?", (leg_id,)).fetchone() + if not cur or (cur[0] or "").strip().lower() in ("invalidated", "filled", "cancelled"): + return + _cancel_roll_leg_order(cfg, group, leg) + conn.execute( + "UPDATE roll_legs SET status='invalidated' WHERE id=? AND status='pending'", + (leg_id,), + ) + _send_roll_invalidate_wechat(cfg, group, leg, mark, reason=reason) + + +def _notify_roll_fail(cfg: dict, group: dict, leg: dict, mark: float, reason: str) -> None: + notify = cfg.get("send_wechat") + if not callable(notify): + return + sym = group.get("symbol") or "" + mode = leg.get("add_mode") or "滚仓" + acct = _wechat_account(cfg) + notify( + f"# ❌ {sym} 滚仓触价成交失败\n" + f"**账户:{acct}**\n" + f"- 方式:{mode}\n" + f"- 原因:{reason}\n" + ) + + def _send_roll_invalidate_wechat( - cfg: dict, group: dict, leg: dict, mark: float + cfg: dict, group: dict, leg: dict, mark: float, *, reason: str = "" ) -> None: notify = cfg.get("send_wechat") if not callable(notify): return sym = group.get("symbol") or "" direction = (group.get("direction") or "long").strip().lower() - mode = leg.get("add_mode") or "斐波限价" + mode = leg.get("add_mode") or "滚仓监控" fmt = cfg.get("format_price") mark_txt = fmt(sym, mark) if callable(fmt) else str(mark) acct = _wechat_account(cfg) dir_txt = _wechat_dir(cfg, direction) + detail = reason or "条件不满足" notify( - f"# ⚠️ {sym} 滚仓斐波挂单失效\n" + f"# ⚠️ {sym} 滚仓监控失效\n" f"**账户:{acct}**\n" f"- 方式:{mode}|{dir_txt}\n" - f"- 标记价 {mark_txt} 已触达止盈侧(未成交),已撤限价加仓单\n" - f"- 本条滚仓腿已结案,可继续下一档或重新挂单\n" + f"- 标记价 {mark_txt}|{detail}\n" + f"- 本条监控已结案,可重新提交\n" ) diff --git a/strategy_templates/strategy_roll.html b/strategy_templates/strategy_roll.html index 8649b79..1c12d93 100644 --- a/strategy_templates/strategy_roll.html +++ b/strategy_templates/strategy_roll.html @@ -5,119 +5,14 @@ 顺势加仓 · {{ exchange_display }} - -
-
-

策略交易 · 顺势加仓 {{ exchange_display }}

-
- - -
-
- +
{% with messages = get_flashed_messages() %}{% if messages %}
{{ messages[0] }}
{% endif %}{% endwith %} - -
-

规则说明

-
- 仅人工加仓,程序不会自动触发。须先在「实盘下单」有同向持仓。
- 做多最多滚仓 3 次;止盈锁定首仓不变;每次填写止损偏移%(相对合并均价,默认 1%),总风险%按「合并持仓打到新止损≈账户风险」反推张数。
- 斐波限价:上沿 H、下沿 L 仅用于算 0.618/0.786 加仓价(多:下沿=止损侧;空:上沿=止损侧)。
- {% if trend_active %}当前有运行中的趋势回调计划,请先结束后再滚仓。{% endif %} -
-
- - - - - - - - -
-

建议执行前用浏览器开发者工具 POST /strategy/roll/preview 查看 JSON 预览(或将加入页面内预览按钮)。

-
- -
-

活跃滚仓组

- - - {% for g in roll_groups %} - - - - - - - - - - - {% else %} - - {% endfor %} -
ID币种方向腿数首仓TP当前SL当前均价止盈盈利U
{{ g.id }}{{ g.symbol }}{{ g.direction }}{{ g.leg_count }}{% if price_fmt %}{{ price_fmt(g.symbol, g.initial_take_profit) }}{% else %}{{ g.initial_take_profit }}{% endif %}{% if price_fmt %}{{ price_fmt(g.symbol, g.current_stop_loss) }}{% else %}{{ g.current_stop_loss }}{% endif %}{% if g.avg_entry_display %}{{ g.avg_entry_display }}{% elif g.avg_entry is not none %}{{ g.avg_entry }}{% else %}—{% endif %}{% if g.reward_at_tp_usdt is not none %}{{ '%.2f'|format(g.reward_at_tp_usdt) }}{% else %}—{% endif %}
暂无
-
- -
-

最近滚仓腿

- - - {% for leg in roll_legs %} - - - - - - - - - - - {% else %} - - {% endfor %} -
#方式张数新SL当前均价止盈盈利U状态
{{ leg.leg_index }}{{ leg.roll_group_id }}{{ leg.add_mode }}{{ leg.amount }}{{ leg.new_stop_loss }}{% if leg.avg_entry_display %}{{ leg.avg_entry_display }}{% elif leg.avg_entry_after is not none %}{{ leg.avg_entry_after }}{% else %}—{% endif %}{% if leg.reward_at_tp_usdt is not none %}{{ '%.2f'|format(leg.reward_at_tp_usdt) }}{% else %}—{% endif %}{{ leg.status_label or leg.status }}
暂无
-
+ {% include 'strategy_roll_panel.html' %} +

顺势加仓完整逻辑说明

diff --git a/strategy_templates/strategy_roll_docs.html b/strategy_templates/strategy_roll_docs.html new file mode 100644 index 0000000..ce5e6e2 --- /dev/null +++ b/strategy_templates/strategy_roll_docs.html @@ -0,0 +1,41 @@ + + + + + + + 顺势加仓 · 详细说明 · {{ exchange_display }} + + + + + +
+ +
+ {{ doc_html|safe }} +
+
+ + diff --git a/strategy_templates/strategy_roll_panel.html b/strategy_templates/strategy_roll_panel.html index 856f78d..9677321 100644 --- a/strategy_templates/strategy_roll_panel.html +++ b/strategy_templates/strategy_roll_panel.html @@ -1,41 +1,57 @@ -
+

顺势加仓

-
+
顺势加仓规则说明{% if roll_trend_active %} · 当前有趋势回调计划{% endif %}
- 仅人工加仓,程序不会自动触发。须先在「实盘下单」有同向持仓。
- 做多最多滚仓 3 次;止盈锁定首仓不变;每次填写止损偏移%(相对合并均价,默认 1%),总风险%按「合并持仓打到新止损≈账户风险」反推张数。
- 斐波限价:上沿 H、下沿 L 仅用于算 0.618/0.786 加仓价(多:下沿=止损侧;空:上沿=止损侧)。
- 仓位上限冻结时仍可顺势加仓(在已有同向监控持仓上操作,不占用新仓名额)。
+ 仅人工提交;须先在「实盘下单」有同向持仓。仅以损定仓模式可用。
+ 做多/做空各最多滚仓 3 次(仅计已成交腿);止盈锁定首仓不变。
+ 风险比例读取所选监控单,不可手改;打到新止损时合并持仓亏损 ≈ 1 个风险单位(当前基数 × 监控 risk%)。
+ 斐波/突破为程序监控(mark 价穿越触发),触价后市价加仓;同时仅允许 1 条监控中腿,提交后不可修改,可删除。
+ 手动平仓后滚仓监控自动结束;已成交腿历史保留供复盘。
+ → 顺势加仓完整逻辑说明
{% if roll_trend_active %}当前有运行中的趋势回调计划,请先结束后再滚仓。{% endif %}
-
- {% for o in roll_monitors %} - + {% endfor %} - - + - - - - - + + + + + + + + + +
-
- 滚仓预览接口说明 -
执行前可用开发者工具 POST /strategy/roll/preview 查看 JSON 预览。
-
+ +

活跃滚仓组

@@ -61,17 +77,23 @@

最近滚仓腿

- + {% for leg in roll_legs %} - + + - - + {% else %} @@ -79,3 +101,4 @@
#方式张数新SL当前均价止盈盈利U状态
#方式张数触发/限价新SL状态操作
{{ leg.leg_index }} {{ leg.roll_group_id }} {{ leg.add_mode }}{{ leg.amount }}{% if leg.amount %}{{ leg.amount }}{% else %}—{% endif %}{% if leg.limit_price %}{{ leg.limit_price }}{% elif leg.breakthrough_price %}{{ leg.breakthrough_price }}{% elif leg.fill_price %}{{ leg.fill_price }}{% else %}—{% endif %} {{ leg.new_stop_loss }}{% if leg.avg_entry_display %}{{ leg.avg_entry_display }}{% elif leg.avg_entry_after is not none %}{{ leg.avg_entry_after }}{% else %}—{% endif %}{% if leg.reward_at_tp_usdt is not none %}{{ '%.2f'|format(leg.reward_at_tp_usdt) }}{% else %}—{% endif %} {{ leg.status_label or leg.status }} + {% if leg.status == 'pending' %} +
+ +
+ {% else %}—{% endif %} +
暂无
+ diff --git a/tests/test_strategy_roll_lib.py b/tests/test_strategy_roll_lib.py index e652273..a01b063 100644 --- a/tests/test_strategy_roll_lib.py +++ b/tests/test_strategy_roll_lib.py @@ -1,29 +1,24 @@ from strategy_roll_lib import ( preview_roll, - resolve_roll_stop_spec, - roll_stop_after_fill, - unified_stop_from_avg, + roll_breakout_invalidate, + roll_breakout_trigger_crossed, + roll_fib_invalidate, + roll_fib_trigger_crossed, + solve_add_amount_for_total_risk, ) -def test_resolve_roll_stop_spec_treats_small_value_as_offset_pct(): - mode, val = resolve_roll_stop_spec(new_stop_loss=1.0, entry_ref=63.976) - assert mode == "offset" - assert val == 1.0 +def test_solve_add_amount_long_one_risk(): + q2, err = solve_add_amount_for_total_risk( + "long", 1.0, 3000.0, 3100.0, 2950.0, 200.0, 1.0 + ) + assert err is None + avg = (1 * 3000 + q2 * 3100) / (1 + q2) + loss = (avg - 2950) * (1 + q2) + assert abs(loss - 200.0) < 0.01 -def test_resolve_roll_stop_spec_treats_price_as_absolute(): - mode, val = resolve_roll_stop_spec(new_stop_loss=64.6, entry_ref=63.976) - assert mode == "absolute" - assert val == 64.6 - - -def test_unified_stop_from_avg_short_one_percent(): - sl = unified_stop_from_avg("short", 63.976, 1.0) - assert abs(sl - 63.976 * 1.01) < 1e-6 - - -def test_preview_roll_offset_mode_not_breakeven(): +def test_preview_roll_market_short(): preview, err = preview_roll( direction="short", symbol="HYPE/USDT", @@ -31,29 +26,45 @@ def test_preview_roll_offset_mode_not_breakeven(): entry_existing=65.0, initial_take_profit=60.0, add_mode="market", - stop_offset_pct=1.0, + new_stop_loss=66.5, risk_percent=2.0, capital_base_usdt=1000.0, add_price=64.0, legs_done=1, ) assert err is None - assert preview["stop_mode"] == "offset" - assert preview["stop_offset_pct"] == 1.0 - avg = preview["avg_entry_after"] + assert preview["add_mode_label"] == "市价加仓" sl = preview["new_stop_loss"] - assert sl > avg * 1.009 - assert sl < avg * 1.011 + avg = preview["avg_entry_after"] + qty = preview["qty_after"] + loss = (sl - avg) * qty + assert abs(loss - 20.0) < 0.01 -def test_roll_stop_after_fill_recomputes_from_actual_fill(): - sl = roll_stop_after_fill( - "short", - qty_before=3.0, - entry_before=65.0, - add_qty=5.0, - fill_price=63.5, - stop_offset_pct=1.0, +def test_fib_cross_long_down(): + assert roll_fib_trigger_crossed("long", 101.0, 100.0, 100.5) is True + assert roll_fib_trigger_crossed("long", 100.6, 100.6, 100.5) is False + + +def test_breakout_cross_long_up(): + assert roll_breakout_trigger_crossed("long", 99.0, 100.5, 100.0) is True + assert roll_breakout_invalidate("long", 98.0, 99.0) is True + assert roll_fib_invalidate("long", 110.0, 105.0, 95.0) is True + + +def test_preview_breakout_mode_label(): + preview, err = preview_roll( + direction="long", + symbol="ETH/USDT", + qty_existing=1.0, + entry_existing=3000.0, + initial_take_profit=3500.0, + add_mode="breakout", + new_stop_loss=2980.0, + breakthrough_price=3100.0, + risk_percent=10.0, + capital_base_usdt=1000.0, + add_price=3150.0, ) - avg = (3 * 65.0 + 5 * 63.5) / 8.0 - assert abs(sl - avg * 1.01) < 1e-6 + assert err is None + assert preview["add_mode_label"] == "突破加仓" diff --git a/策略交易说明.md b/策略交易说明.md index 4b43ff4..ee7411a 100644 --- a/策略交易说明.md +++ b/策略交易说明.md @@ -57,6 +57,8 @@ strategy_records_register.py # /strategy/records 路由与列表数据 ## 四、顺势加仓(滚仓,仅人工) +> **详细说明**(计仓公式、四种方式、程序监控、生命周期):仓库 [`顺势加仓滚仓说明.md`](./顺势加仓滚仓说明.md);各实例策略页 **[`/strategy/roll/docs`](/strategy/roll/docs)** 可在线阅读。 + ### 4.1 原则 - **禁止自动加仓**;仅页面按钮「执行滚仓」或挂限价单(无价格穿越自动下单)。 diff --git a/顺势加仓滚仓说明.md b/顺势加仓滚仓说明.md new file mode 100644 index 0000000..733b046 --- /dev/null +++ b/顺势加仓滚仓说明.md @@ -0,0 +1,174 @@ +# 顺势加仓(滚仓)详细说明 + +本文档描述 **顺势加仓 / 滚仓** 的完整业务逻辑、计仓公式、四种加仓方式、程序监控与生命周期规则。实现代码见 `strategy_roll_lib.py`、`strategy_roll_monitor_lib.py`、`strategy_register.py`。 + +--- + +## 1. 适用范围与前置条件 + +| 项目 | 规则 | +|------|------| +| 计仓模式 | **仅「以损定仓」**(`POSITION_SIZING_MODE=risk`);全仓杠杆模式禁止滚仓 | +| 持仓 | 须先在「实盘下单」存在 **active** 的 `order_monitors`,且交易所有同向持仓 | +| 趋势互斥 | 存在 **active** 趋势回调计划时不可滚仓 | +| 腿数上限 | 做多 / 做空各最多 **3 次**滚仓(仅计 **已成交** 的 `roll_legs`) | +| 同时监控 | **同一滚仓组** 最多 **1 条 pending** 腿;成交或删除/失效后再提交下一腿 | +| 止盈 | 全程使用 **首仓** `order_monitors.take_profit`,滚仓不改止盈 | +| 止损 | 每次提交填写 **新统一止损价 S**;成交后交易所 TP/SL 同步(止盈仍为首仓) | + +--- + +## 2. 风险预算(不可手改) + +- 读取所选监控单:`order_monitors.risk_percent` +- 风险预算:**B = 当前交易基数 × risk%**(`get_trading_capital_usdt()` × 监控 risk%) +- 页面规则区展示当前 risk%,表单 **不提供** 风险% 输入框 + +**方案 C(定稿)**:加仓后若价格打到 **新止损 S**,合并持仓的总亏损 **≤ B**(约等于 1 个风险单位)。浮盈通过 **触发时刻的 mark 价、当时持仓均价与张数** 进入公式,不在提交时固定张数。 + +--- + +## 3. 计仓公式 + +变量: + +- `Q1, E1`:触发时现有持仓张数、均价 +- `E2`:加仓成交价(市价腿 ≈ 当时 mark;程序监控腿在 **穿越触发时** 用当时 mark 重算) +- `S`:提交时填写的统一止损价 +- `B`:风险预算(U) +- `cs`:合约 `contractSize`(U 本位线性永续) + +**做多**(须 `S < E2`): + +```text +(Q1 + Q2) × (avg − S) × cs = B +avg = (Q1·E1 + Q2·E2) / (Q1 + Q2) + +=> Q2 = (B/cs − Q1·(E1 − S)) / (E2 − S) +``` + +**做空**(须 `S > E2`): + +```text +=> Q2 = (B/cs − Q1·(S − E1)) / (S − E2) +``` + +若 `Q2 ≤ 0`:不加仓 / 监控腿 **失效**,提示「已满足风险上限或无法再加」。 + +预览与市价执行前用当前 mark 估算;**斐波 / 突破** 在 **触发瞬间** 按当时持仓与 mark **重新计算** 张数后再市价下单。 + +--- + +## 4. 四种加仓方式 + +### 4.1 市价加仓 + +| 输入 | 仅 **新止损价 S** | +| 执行 | 预览 → **10 秒确认** → 立即市价成交 → 更新止损 | +| 显示 | `市价加仓` | + +### 4.2 斐波 0.618 / 0.786 + +| 输入 | 上沿 H、下沿 L、新止损 S | +| 限价 | 由 H/L 按斐波算 **加仓价 P**(不打交易所限价单) | +| 触发 | 程序监控 **mark**:
• **多**:mark **向下穿越** P → 市价加
• **空**:mark **向上穿越** P → 市价加 | +| 失效 | **止盈侧**:多 mark≥H;空 mark≤L | +| 显示 | `斐波0.618` / `斐波0.786` | + +### 4.3 突破加仓 + +| 输入 | **突破价 B**、新止损 S | +| 触发 | 程序监控 **mark**:
• **多**:mark **向上穿越** B → 市价加
• **空**:mark **向下穿越** B → 市价加 | +| 失效 | **止损侧**:多 mark≤S;空 mark≥S(未突破先向止损侧) | +| 显示 | `突破加仓` | + +几何校验(做多示例): + +- 斐波:S < P < 当前价(回调加仓) +- 突破:S < B < 当前价(向上突破再加) + +--- + +## 5. 程序监控技术要点 + +- **监控价**:统一使用 **标记价 mark**(`get_mark_price` 或 `get_price`) +- **穿越判定**:比较 `last_mark_price`(上一 tick 存库)与当前 mark,避免重复触发 + - 例:做多斐波:`prev > P` 且 `mark ≤ P` +- **轮询**:各所后台任务调用 `check_roll_monitors(cfg)` +- **成交后**:`replace_tpsl` 更新交易所止损;`order_monitors.stop_loss` 同步为 S + +--- + +## 6. 生命周期与权限 + +```text +提交 pending → [监控中] ──穿越触发──→ filled → 可提交下一腿 + │ + ├── 用户删除 → cancelled(不可修改,仅删除) + ├── 失效规则 → invalidated + └── 手动平仓 / 监控结案 → roll_group closed,pending 清除 +``` + +| 规则 | 说明 | +|------|------| +| 提交后不可改 | pending 腿参数不可编辑,只能 **删除** | +| 手动平仓 | 实例页删单/平仓、中控持仓平仓 → 调用 `roll_sync_after_external_close` | +| 历史保留 | **filled** 腿写入库与策略复盘快照;关组后 pending 清除,已成交腿仍可在「策略交易记录」中查看 | + +API: + +- `POST /strategy/roll/preview` — JSON 预览 +- `POST /strategy/roll/execute` — 提交市价或监控计划 +- `POST /strategy/roll/cancel/` — 删除 pending 腿 +- `POST /api/hub/roll/sync-flat` — 中控平仓后同步(内部) + +--- + +## 7. 数据表 + +**roll_groups**(绑定 `order_monitor_id`) + +- 首仓 TP/SL、`current_stop_loss`、`leg_count`(**已成交**次数)、`risk_percent` 快照 + +**roll_legs** + +| 字段 | 说明 | +|------|------| +| add_mode | 市价加仓 / 斐波0.618 / 斐波0.786 / 突破加仓 | +| limit_price | 斐波限价 P | +| breakthrough_price | 突破价 B | +| new_stop_loss | 统一止损 S | +| last_mark_price | 上一 tick mark(穿越检测) | +| status | pending / filled / cancelled / invalidated | + +--- + +## 8. 操作流程(建议) + +1. 在「实盘下单」已有同向持仓与监控单 +2. 打开 **策略交易 → 顺势加仓**,选择币种(方向自动锁定) +3. 选择加仓方式,填写对应价格字段 → **预览** +4. 市价:等待 10 秒 → **执行滚仓**;斐波/突破:确认后提交监控 +5. 监控中可在「最近滚仓腿」**删除**;成交后再提交下一腿(最多 3 次) + +--- + +## 9. 相关文件 + +| 文件 | 职责 | +|------|------| +| `strategy_roll_lib.py` | 计仓、校验、穿越/失效纯函数 | +| `strategy_roll_monitor_lib.py` | 定时监控、触价成交、外部平仓同步 | +| `strategy_register.py` | 预览/执行/删除路由 | +| `static/strategy_roll.js` | 方向锁定、字段显隐、预览与 10 秒确认 | +| `strategy_templates/strategy_roll_panel.html` | 右栏 UI | + +--- + +## 10. 与旧版差异摘要 + +- 风险% 从监控单读取,不再手填 +- 止损为 **绝对价格**,不再使用「止损偏移%」 +- 斐波/突破改为 **程序盯 mark + 触价市价**,不再挂交易所限价单 +- 新增 **突破加仓** +- pending **不可改、可删**;手动平仓自动结束滚仓监控