顺势加仓 v2:程序监控滚仓、文档页与平仓同步

重写滚仓计仓与四种加仓方式(市价/斐波/突破),程序盯 mark 触价成交;风险读监控单;pending 可删不可改;手动平仓同步结束滚仓。新增 /strategy/roll/docs 说明页与顺势加仓滚仓说明.md。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-26 22:03:23 +08:00
parent 4aebe70611
commit d467760d5c
17 changed files with 1506 additions and 630 deletions
+16
View File
@@ -8673,6 +8673,14 @@ def del_order(id):
now=app_now(), now=app_now(),
) )
conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, id)) 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) clear_key_sizing_snapshot_if_flat(conn, session_date)
conn.commit() conn.commit()
conn.close() conn.close()
@@ -8740,6 +8748,14 @@ def del_order(id):
now=app_now(), now=app_now(),
) )
conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (id,)) 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.commit()
conn.close() conn.close()
flash("该仓位在交易所已不存在,已按成交记录同步结束并记账") flash("该仓位在交易所已不存在,已按成交记录同步结束并记账")
+16
View File
@@ -8602,6 +8602,14 @@ def del_order(id):
now=app_now(), now=app_now(),
) )
conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, id)) 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) clear_key_sizing_snapshot_if_flat(conn, session_date)
conn.commit() conn.commit()
conn.close() conn.close()
@@ -8670,6 +8678,14 @@ def del_order(id):
now=app_now(), now=app_now(),
) )
conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (id,)) 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.commit()
conn.close() conn.close()
flash("该仓位在交易所已不存在,已按成交记录同步结束并记账") flash("该仓位在交易所已不存在,已按成交记录同步结束并记账")
+16
View File
@@ -8598,6 +8598,14 @@ def del_order(id):
now=app_now(), now=app_now(),
) )
conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, id)) 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) clear_key_sizing_snapshot_if_flat(conn, session_date)
conn.commit() conn.commit()
conn.close() conn.close()
@@ -8666,6 +8674,14 @@ def del_order(id):
now=app_now(), now=app_now(),
) )
conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (id,)) 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.commit()
conn.close() conn.close()
flash("该仓位在交易所已不存在,已按成交记录同步结束并记账") flash("该仓位在交易所已不存在,已按成交记录同步结束并记账")
+16
View File
@@ -8092,6 +8092,14 @@ def del_order(id):
now=app_now(), now=app_now(),
) )
conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, id)) 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.commit()
conn.close() conn.close()
send_wechat_msg( send_wechat_msg(
@@ -8157,6 +8165,14 @@ def del_order(id):
now=app_now(), now=app_now(),
) )
conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (id,)) 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.commit()
conn.close() conn.close()
flash("该仓位在交易所已不存在,已按成交记录同步结束并记账") flash("该仓位在交易所已不存在,已按成交记录同步结束并记账")
+32
View File
@@ -791,6 +791,38 @@ def register_hub_routes(app):
finally: finally:
conn.close() 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/<int:pid>", methods=["POST"]) @app.route("/api/hub/trend/breakeven/<int:pid>", methods=["POST"])
@_hub_auth_required @_hub_auth_required
def api_hub_trend_breakeven(pid): def api_hub_trend_breakeven(pid):
+9
View File
@@ -2143,6 +2143,15 @@ async def api_close_position(exchange_id: str, body: ClosePositionBody):
) )
if isinstance(sync_parsed, dict): if isinstance(sync_parsed, dict):
out["trend_sync"] = sync_parsed 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) risk_sync = await _notify_instance_user_close(flask_client, ex, count=1)
if isinstance(risk_sync, dict): if isinstance(risk_sync, dict):
out["risk_sync"] = risk_sync out["risk_sync"] = risk_sync
+182
View File
@@ -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 =
"<strong>" +
(p.add_mode_label || "") +
"</strong> · 约 <strong>" +
(p.add_amount_display != null ? p.add_amount_display : p.add_amount_raw) +
"</strong> 张<br>" +
"加仓参考价 " +
(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) +
"<br>" +
"合并均价 " +
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();
})();
+2
View File
@@ -155,6 +155,8 @@ def init_strategy_tables(conn) -> None:
"ALTER TABLE order_monitors ADD COLUMN key_signal_type TEXT", "ALTER TABLE order_monitors ADD COLUMN key_signal_type TEXT",
"ALTER TABLE trend_pullback_plans ADD COLUMN leg_fill_prices_json 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 stop_offset_pct REAL",
"ALTER TABLE roll_legs ADD COLUMN breakthrough_price REAL",
"ALTER TABLE roll_legs ADD COLUMN last_mark_price REAL",
): ):
try: try:
conn.execute(ddl) conn.execute(ddl)
+334 -160
View File
@@ -1,15 +1,22 @@
"""策略交易:Flask 路由注册(顺势加仓 + 趋势回调页)。逻辑在 strategy_*_lib。""" """策略交易:Flask 路由注册(顺势加仓 + 趋势回调页)。逻辑在 strategy_*_lib。"""
from __future__ import annotations from __future__ import annotations
import html as html_module
import os import os
from functools import wraps import re
from typing import Any, Callable, Optional 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 jinja2 import ChoiceLoader, FileSystemLoader
from strategy_db import init_strategy_tables 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: 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 差异)。""" """cfg 由各市面 app 注入回调(仅 API / DB 差异)。"""
login_required = cfg["login_required"] login_required = cfg["login_required"]
get_db = cfg["get_db"]
def _lr(f): def _lr(f):
return login_required(f) return login_required(f)
@@ -81,10 +87,11 @@ def register_strategy_trading(app: Flask, cfg: dict[str, Any]) -> None:
if request.is_json: if request.is_json:
return jsonify(err) return jsonify(err)
if err.get("ok"): if err.get("ok"):
p = err["preview"]
flash( flash(
f"预览:加仓{err['preview'].get('add_amount_display', '-')} 张," f"预览:约 {p.get('add_amount_display', '-')} 张,"
f"合并均价 {err['preview'].get('avg_entry_after', '-')}" f"合并均价 {p.get('avg_entry_after', '-')}"
f"触及新止损亏损约 {err['preview'].get('loss_at_sl_usdt', '-')}U" f"打到止损约 {p.get('loss_at_sl_usdt', '-')}U"
) )
else: else:
flash(err.get("msg") or "预览失败") flash(err.get("msg") or "预览失败")
@@ -103,7 +110,109 @@ def register_strategy_trading(app: Flask, cfg: dict[str, Any]) -> None:
flash(msg) flash(msg)
return redirect(url_for("strategy_trading_page")) return redirect(url_for("strategy_trading_page"))
# 趋势回调:仍由各市面 app 注册原有路由;导航指向 /strategy/trend @_lr
@app.route("/strategy/roll/cancel/<int:leg_id>", 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(
"<pre><code>"
+ html_module.escape("\n".join(code_buf))
+ "</code></pre>"
)
code_buf = []
def inline_md(s: str) -> str:
s = html_module.escape(s)
s = re.sub(r"`([^`]+)`", r"<code>\1</code>", s)
s = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", 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"<h1>{inline_md(line[2:].strip())}</h1>")
elif line.startswith("## "):
out.append(f"<h2>{inline_md(line[3:].strip())}</h2>")
elif line.startswith("### "):
out.append(f"<h3>{inline_md(line[4:].strip())}</h3>")
elif line.strip() == "---":
out.append("<hr>")
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("<table>")
hdr = [c.strip() for c in rows[0].strip("|").split("|")]
out.append("<tr>" + "".join(f"<th>{inline_md(c)}</th>" for c in hdr) + "</tr>")
for row in rows[2:]:
cells = [c.strip() for c in row.strip("|").split("|")]
out.append("<tr>" + "".join(f"<td>{inline_md(c)}</td>" for c in cells) + "</tr>")
out.append("</table>")
continue
elif re.match(r"^[-*]\s+", line):
out.append("<ul>")
while i < len(lines) and re.match(r"^[-*]\s+", lines[i]):
item = re.sub(r"^[-*]\s+", "", lines[i])
out.append(f"<li>{inline_md(item)}</li>")
i += 1
out.append("</ul>")
continue
elif line.strip():
out.append(f"<p>{inline_md(line.strip())}</p>")
i += 1
flush_code()
return "\n".join(out)
def _row_to_dict(row) -> dict: def _row_to_dict(row) -> dict:
@@ -129,8 +238,27 @@ def _count_active_trends(conn, cfg: dict) -> int:
return 0 return 0
def _roll_preview_response(cfg: dict, data: dict, json_mode: bool = False) -> dict: def _risk_from_monitor(mon: dict, cfg: dict) -> tuple[Optional[float], Optional[str]]:
"""顺势加仓不占用 MAX_ACTIVE_POSITIONS 新仓名额,故不校验仓位上限冻结。""" 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") m = cfg.get("app_module")
if m is not None: if m is not None:
try: 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" mode = getattr(m, "POSITION_SIZING_MODE", None) or "risk"
ok_src, src_msg = assert_open_source_allowed(mode, OPEN_SOURCE_ROLL) ok_src, src_msg = assert_open_source_allowed(mode, OPEN_SOURCE_ROLL)
if not ok_src: if not ok_src:
return {"ok": False, "msg": src_msg} return None, src_msg
except Exception: except Exception:
pass pass
get_db = cfg["get_db"] get_db = cfg["get_db"]
symbol = cfg["normalize_symbol_input"](data.get("symbol") or "") symbol = cfg["normalize_symbol_input"](data.get("symbol") or "")
if not symbol: if not symbol:
return {"ok": False, "msg": "请选择或填写币种"} return None, "请选择或填写币种"
direction = (data.get("direction") or "long").strip().lower() direction = (data.get("direction") or "long").strip().lower()
ex_sym = cfg["normalize_exchange_symbol"](symbol) ex_sym = cfg["normalize_exchange_symbol"](symbol)
conn = get_db() conn = get_db()
init_strategy_tables(conn) init_strategy_tables(conn)
if _count_active_trends(conn, cfg) > 0: if _count_active_trends(conn, cfg) > 0:
conn.close() conn.close()
return {"ok": False, "msg": "存在运行中的趋势回调计划,请先结束后再滚仓"} return None, "存在运行中的趋势回调计划,请先结束后再滚仓"
mon = _get_active_monitor(conn, cfg, symbol, direction) mon = _get_active_monitor(conn, cfg, symbol, direction)
if not mon: if not mon:
conn.close() conn.close()
return {"ok": False, "msg": "未找到该币种同向的下单监控持仓,请先在「实盘下单」开仓"} return None, "未找到该币种同向的下单监控持仓,请先在「实盘下单」开仓"
rg, legs_done, _is_new = _get_or_create_roll_group_meta(conn, mon) rg, legs_done, pending, roll_is_new = _get_or_create_roll_group_meta(conn, mon)
conn.close() if pending > 0:
pos = cfg["get_position"](ex_sym, direction) conn.close()
qty = float(pos.get("contracts") or 0) return None, "已有监控中的滚仓腿,请等待成交/失效或先删除后再提交"
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": "止损格式错误"}
conn_cap = get_db() conn_cap = get_db()
try: try:
capital = float(cfg["get_trading_capital_usdt"](conn_cap)) capital = float(cfg["get_trading_capital_usdt"](conn_cap))
finally: finally:
conn_cap.close() conn_cap.close()
live = cfg["get_price"](symbol) risk_pct, risk_err = _risk_from_monitor(mon, cfg)
fib_u = fib_l = None 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: try:
if data.get("fib_upper") not in (None, ""): if data.get("fib_upper") not in (None, ""):
fib_u = float(data.get("fib_upper")) fib_u = float(data.get("fib_upper"))
if data.get("fib_lower") not in (None, ""): if data.get("fib_lower") not in (None, ""):
fib_l = float(data.get("fib_lower")) fib_l = float(data.get("fib_lower"))
if data.get("breakthrough_price") not in (None, ""):
bp = float(data.get("breakthrough_price"))
except (TypeError, ValueError): except (TypeError, ValueError):
return {"ok": False, "msg": "斐波上沿/下沿格式错误"} return None, "价格参数格式错误"
preview, err = preview_roll(
direction=direction, add_price = ctx.get("mark")
symbol=symbol, if add_mode == MARKET_MODE:
qty_existing=qty, if add_price is None or add_price <= 0:
entry_existing=entry, return None, "无法获取市价快照"
initial_take_profit=tp0, elif add_mode in FIB_MODES:
add_mode=add_mode, if fib_u is None or fib_l is None:
new_stop_loss=new_sl_abs, return None, "斐波须填写上沿 H 与下沿 L"
stop_offset_pct=stop_offset_pct, elif add_mode == BREAKOUT_MODE:
risk_percent=risk_pct, if bp is None:
capital_base_usdt=capital, return None, "突破加仓须填写突破价"
add_price=float(live) if live else None, add_price = ctx.get("mark")
fib_upper=fib_u, else:
fib_lower=fib_l, return None, "加仓方式无效"
legs_done=legs_done,
) 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: if err:
return {"ok": False, "msg": 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_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["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") price_fmt = cfg.get("price_fmt")
if callable(price_fmt): if callable(price_fmt):
preview["add_price_display"] = price_fmt(symbol, preview["add_price"]) preview["add_price_display"] = price_fmt(ctx["symbol"], preview["add_price"])
preview["new_sl_display"] = price_fmt(symbol, preview["new_stop_loss"]) preview["new_sl_display"] = price_fmt(ctx["symbol"], preview["new_stop_loss"])
preview["tp_display"] = price_fmt(symbol, preview["initial_take_profit"]) preview["tp_display"] = price_fmt(ctx["symbol"], preview["initial_take_profit"])
return {"ok": True, "preview": preview} return {"ok": True, "preview": preview}
@@ -250,9 +432,8 @@ def _roll_execute(cfg: dict, data: dict) -> tuple[bool, str]:
direction = preview["direction"] direction = preview["direction"]
ex_sym = cfg["normalize_exchange_symbol"](symbol) ex_sym = cfg["normalize_exchange_symbol"](symbol)
add_mode = preview["add_mode"] add_mode = preview["add_mode"]
amount = cfg["amount_to_precision"](ex_sym, float(preview["add_amount_raw"])) new_sl = float(preview["new_stop_loss"])
if amount is None or amount <= 0: tp0 = float(preview["initial_take_profit"])
return False, "加仓张数低于交易所最小精度"
lev_fn = cfg.get("default_leverage") lev_fn = cfg.get("default_leverage")
if not callable(lev_fn): if not callable(lev_fn):
lev_fn = lambda _s: 5 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) mon = _get_active_monitor(conn, cfg, symbol, direction)
if not mon: if not mon:
return False, "监控单已不存在" return False, "监控单已不存在"
rg, legs_done, roll_is_new = _get_or_create_roll_group_meta(conn, mon) rg, legs_done, pending, roll_is_new = _get_or_create_roll_group_meta(conn, mon)
new_sl = float(preview["new_stop_loss"]) if pending > 0:
stop_offset_pct = preview.get("stop_offset_pct") return False, "已有监控中的滚仓腿,请先删除或等待结束"
tp0 = float(preview["initial_take_profit"]) if add_mode == MARKET_MODE:
qty_before = float(preview.get("qty_existing") or 0) amount = cfg["amount_to_precision"](ex_sym, float(preview["add_amount_raw"]))
entry_before = float(preview.get("entry_existing") or 0) if amount is None or amount <= 0:
if add_mode == "market": return False, "加仓张数低于交易所最小精度"
order = cfg["market_add"](ex_sym, direction, amount, leverage) 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"]) fill = float(
status = "filled" cfg.get("resolve_fill_price", lambda o, s, p: p)(
oid = str(order.get("id") or "") if isinstance(order, dict) else "" order, ex_sym, preview["add_price"]
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),
) )
px_fn = cfg.get("price_to_precision") or preview["add_price"]
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)
oid = str(order.get("id") or "") if isinstance(order, dict) else "" oid = str(order.get("id") or "") if isinstance(order, dict) else ""
cfg["replace_tpsl"](ex_sym, direction, new_sl, tp0, mon)
conn.execute( conn.execute(
"""INSERT INTO roll_legs ( """INSERT INTO roll_legs (
roll_group_id, leg_index, add_mode, fib_upper, fib_lower, limit_price, 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 breakthrough_price, fill_price, amount, new_stop_loss, exchange_order_id,
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""", status, created_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
( (
rg["id"], rg["id"],
legs_done + 1, legs_done + 1,
preview["add_mode_label"], preview["add_mode_label"],
preview.get("fib_upper"), preview.get("fib_upper"),
preview.get("fib_lower"), preview.get("fib_lower"),
price, None,
preview.get("breakthrough_price"),
fill,
amount, amount,
new_sl, new_sl,
stop_offset_pct,
oid, oid,
"pending", "filled",
cfg["app_now_str"](), cfg["app_now_str"](),
), ),
) )
conn.execute( conn.execute(
"UPDATE roll_groups SET leg_count=?, updated_at=? WHERE id=?", "UPDATE roll_groups SET leg_count=?, current_stop_loss=?, updated_at=? WHERE id=?",
(legs_done + 1, cfg["app_now_str"](), rg["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() conn.commit()
if roll_is_new: _maybe_notify_roll_started(cfg, rg, mon, symbol, direction, tp0, new_sl, roll_is_new=roll_is_new)
try: return True, f"市价加仓第 {legs_done + 1} 腿已成交,止损已更新,止盈仍为首仓"
from strategy_wechat_notify import notify_roll_group_started # 程序监控:斐波 / 突破
limit_px = None
notify_roll_group_started( if add_mode in FIB_MODES:
cfg, px_fn = cfg.get("price_to_precision")
group_id=int(rg["id"]), limit_px = float(preview["add_price"])
symbol=symbol, if callable(px_fn):
direction=direction, limit_px = float(px_fn(ex_sym, limit_px) or limit_px)
order_monitor_id=int(mon["id"]), mark_fn = cfg.get("get_mark_price") or cfg.get("get_price")
initial_take_profit=tp0, last_mark = mark_fn(symbol) if callable(mark_fn) else preview["add_price"]
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)
conn.execute( conn.execute(
"""INSERT INTO roll_legs ( """INSERT INTO roll_legs (
roll_group_id, leg_index, add_mode, fib_upper, fib_lower, limit_price, 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 breakthrough_price, new_stop_loss, last_mark_price, status, created_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""", ) VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
( (
rg["id"], rg["id"],
legs_done + 1, legs_done + 1,
preview["add_mode_label"], preview["add_mode_label"],
preview.get("fib_upper"), preview.get("fib_upper"),
preview.get("fib_lower"), preview.get("fib_lower"),
None, limit_px,
fill, preview.get("breakthrough_price"),
amount,
new_sl, new_sl,
stop_offset_pct, last_mark,
oid, "pending",
"filled",
cfg["app_now_str"](), 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() conn.commit()
try: _maybe_notify_roll_started(cfg, rg, mon, symbol, direction, tp0, new_sl, roll_is_new=roll_is_new)
from strategy_wechat_notify import ( return True, f"已提交{preview['add_mode_label']}监控,触价后将市价加仓并更新止损"
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}"
except Exception as e: except Exception as e:
fe = cfg.get("friendly_error") fe = cfg.get("friendly_error")
return False, fe(e) if callable(fe) else str(e) 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 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]: def _get_active_monitor(conn, cfg: dict, symbol: str, direction: str) -> Optional[dict]:
row = conn.execute( row = conn.execute(
"SELECT * FROM order_monitors WHERE status='active' AND symbol=? AND direction=? ORDER BY id DESC LIMIT 1", "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 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( row = conn.execute(
"SELECT * FROM roll_groups WHERE order_monitor_id=? AND status='active' ORDER BY id DESC LIMIT 1", "SELECT * FROM roll_groups WHERE order_monitor_id=? AND status='active' ORDER BY id DESC LIMIT 1",
(mon["id"],), (mon["id"],),
).fetchone() ).fetchone()
if row: if row:
d = _row_to_dict(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 "" now = mon.get("created_at") or ""
cur = conn.execute( cur = conn.execute(
"""INSERT INTO roll_groups ( """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"), "direction": mon.get("direction"),
}, },
0, 0,
0,
True, 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="手动平仓,滚仓监控已结束"
)
+248 -173
View File
@@ -1,14 +1,23 @@
"""顺势加仓(滚仓):纯计算。人工触发;止盈锁定首仓;斐波仅算限价""" """顺势加仓(滚仓):纯计算。人工触发;止盈锁定首仓;程序监控触价市价成交"""
from __future__ import annotations from __future__ import annotations
from typing import Any, Optional, Tuple 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_LONG = 3
ROLL_MAX_LEGS_SHORT = 3 ROLL_MAX_LEGS_SHORT = 3
ROLL_STOP_OFFSET_PCT_DEFAULT = 1.0
MARKET_MODE = "market"
FIB_MODES = frozenset({"fib_618", "fib_786"}) 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]: def fib_ratio_from_mode(mode: str) -> Optional[float]:
@@ -20,6 +29,11 @@ def fib_ratio_from_mode(mode: str) -> Optional[float]:
return None 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]]: def fib_limit_entry(direction: str, upper: float, lower: float, mode: str) -> Tuple[Optional[float], Optional[str]]:
"""H/L 仅用于计算限价加仓价;多:下沿=止损侧;空:上沿=止损侧。""" """H/L 仅用于计算限价加仓价;多:下沿=止损侧;空:上沿=止损侧。"""
ratio = fib_ratio_from_mode(mode) 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 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( def avg_entry_after_add(
qty_existing: float, qty_existing: float,
entry_existing: float, entry_existing: float,
@@ -102,44 +73,8 @@ def avg_entry_after_add(
return (q1 * e1 + q2 * e2) / total return (q1 * e1 + q2 * e2) / total
def solve_add_amount_for_avg_stop_offset( def calc_risk_budget_usdt(capital_base_usdt: float, risk_percent: float) -> float:
direction: str, return float(capital_base_usdt) * (float(risk_percent) / 100.0)
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 solve_add_amount_for_total_risk( def solve_add_amount_for_total_risk(
@@ -149,11 +84,12 @@ def solve_add_amount_for_total_risk(
add_price: float, add_price: float,
new_stop: float, new_stop: float,
risk_budget_usdt: float, risk_budget_usdt: float,
contract_size: float = 1.0,
) -> Tuple[Optional[float], Optional[str]]: ) -> Tuple[Optional[float], Optional[str]]:
""" """
已知合并后若触及 new_stop 总亏损=risk_budget,反推本次加仓张数 Q2 合并持仓打到 new_stop 总亏损risk_budget(方案 C
long: (avg - SL) * (Q1+Q2) = B => Q2 = (B - Q1*(E1-SL)) / (E2-SL) long: (avg - SL) * (Q1+Q2) * cs = B => Q2 = (B/cs - Q1*(E1-SL)) / (E2-SL)
short: (SL - avg) * (Q1+Q2) = B => Q2 = (B - Q1*(SL-E1)) / (SL-E2) short: (SL - avg) * (Q1+Q2) * cs = B => Q2 = (B/cs - Q1*(SL-E1)) / (SL-E2)
""" """
try: try:
q1 = float(qty_existing) q1 = float(qty_existing)
@@ -161,27 +97,196 @@ def solve_add_amount_for_total_risk(
e2 = float(add_price) e2 = float(add_price)
sl = float(new_stop) sl = float(new_stop)
b = float(risk_budget_usdt) b = float(risk_budget_usdt)
cs = float(contract_size) if contract_size else 1.0
except (TypeError, ValueError): except (TypeError, ValueError):
return None, "参数格式错误" 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, "持仓或风险预算无效" return None, "持仓或风险预算无效"
direction = (direction or "long").strip().lower() direction = (direction or "long").strip().lower()
if direction == "short": if direction == "short":
denom = sl - e2 denom = sl - e2
numer = b - q1 * (sl - e1) numer = b / cs - q1 * (sl - e1)
if denom <= 0: if denom <= 0:
return None, "做空:新止损须高于限价加仓价" return None, "做空:新止损须高于加仓价"
else: else:
denom = e2 - sl denom = e2 - sl
numer = b - q1 * (e1 - sl) numer = b / cs - q1 * (e1 - sl)
if denom <= 0: if denom <= 0:
return None, "做多:新止损须低于限价/市价加仓价" return None, "做多:新止损须低于加仓价"
q2 = numer / denom q2 = numer / denom
if q2 <= 0: if q2 <= 0:
return None, "按当前新止损与风险%,无需加仓或无法再加(已满足风险上限)" return None, "按当前新止损与风险预算,无需加仓或无法再加(已满足风险上限)"
return q2, 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( def preview_roll(
*, *,
direction: str, direction: str,
@@ -191,92 +296,78 @@ def preview_roll(
initial_take_profit: float, initial_take_profit: float,
add_mode: str, add_mode: str,
new_stop_loss: Optional[float] = None, new_stop_loss: Optional[float] = None,
stop_offset_pct: Optional[float] = None,
risk_percent: float, risk_percent: float,
capital_base_usdt: float, capital_base_usdt: float,
add_price: Optional[float] = None, add_price: Optional[float] = None,
fib_upper: Optional[float] = None, fib_upper: Optional[float] = None,
fib_lower: Optional[float] = None, fib_lower: Optional[float] = None,
breakthrough_price: Optional[float] = None,
legs_done: int = 0, legs_done: int = 0,
contract_size: float = 1.0,
) -> Tuple[Optional[dict[str, Any]], Optional[str]]: ) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
direction = (direction or "long").strip().lower() direction = (direction or "long").strip().lower()
if legs_done >= max_roll_legs(direction): if legs_done >= max_roll_legs(direction):
return None, f"{'做多' if direction == 'long' else '做空'}滚仓已达 {max_roll_legs(direction)} 次上限" return None, f"{'做多' if direction == 'long' else '做空'}滚仓已达 {max_roll_legs(direction)} 次上限"
mode = (add_mode or "market").strip().lower() mode = (add_mode or MARKET_MODE).strip().lower()
if mode == "market": if new_stop_loss is None:
if add_price is None or add_price <= 0: return None, "请填写新止损价"
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, "加仓方式无效"
try: try:
tp = float(initial_take_profit) sl = float(new_stop_loss)
except (TypeError, ValueError): except (TypeError, ValueError):
return None, "格式错误" return None, "损价格式错误"
if tp <= 0: if sl <= 0:
return None, "首仓止盈须大于0" return None, "止损须大于0"
stop_mode, stop_val = resolve_roll_stop_spec(
new_stop_loss=new_stop_loss, geom_err = validate_roll_geometry(
stop_offset_pct=stop_offset_pct, direction,
entry_ref=entry_existing, 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 geom_err:
if tp <= entry_existing: return None, geom_err
return None, "做多:首仓止盈须高于当前持仓均价参考"
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: else:
if tp >= entry_existing: entry_add = float(breakthrough_price or 0)
return None, "做空:首仓止盈须低于当前持仓均价参考"
risk_budget = float(capital_base_usdt) * (float(risk_percent) / 100.0) risk_budget = calc_risk_budget_usdt(capital_base_usdt, risk_percent)
offset_pct: Optional[float] = None q2_raw, err = solve_add_amount_for_total_risk(
if stop_mode == "offset": direction,
offset_pct = float(stop_val) qty_existing,
q2_raw, err = solve_add_amount_for_avg_stop_offset( entry_existing,
direction, qty_existing, entry_existing, entry_add, offset_pct, risk_budget entry_add,
) sl,
else: risk_budget,
sl = float(stop_val) contract_size,
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
)
if err: if err:
return None, err return None, err
q2 = float(q2_raw) q2 = float(q2_raw)
new_qty = qty_existing + q2 new_qty = qty_existing + q2
new_avg = avg_entry_after_add(qty_existing, entry_existing, q2, entry_add) new_avg = avg_entry_after_add(qty_existing, entry_existing, q2, entry_add)
if stop_mode == "offset": cs = float(contract_size or 1.0)
sl = unified_stop_from_avg(direction, new_avg, offset_pct) loss_sl = loss_at_stop_usdt(direction, new_avg, new_qty, sl, cs)
if direction == "long": reward_tp = reward_at_tp_usdt(direction, new_avg, initial_take_profit, new_qty, cs)
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
return { return {
"symbol": symbol, "symbol": symbol,
"direction": direction, "direction": direction,
"add_mode": mode, "add_mode": mode,
"add_mode_label": mode_label, "add_mode_label": mode_label(mode),
"add_price": round(entry_add, 10), "add_price": round(entry_add, 10),
"new_stop_loss": round(sl, 10), "new_stop_loss": round(sl, 10),
"stop_offset_pct": offset_pct, "breakthrough_price": float(breakthrough_price) if breakthrough_price not in (None, "") else None,
"stop_mode": stop_mode, "initial_take_profit": float(initial_take_profit),
"initial_take_profit": tp,
"risk_percent": float(risk_percent), "risk_percent": float(risk_percent),
"risk_budget_usdt": round(risk_budget, 4), "risk_budget_usdt": round(risk_budget, 4),
"add_amount_raw": q2, "add_amount_raw": q2,
@@ -284,27 +375,11 @@ def preview_roll(
"entry_existing": float(entry_existing), "entry_existing": float(entry_existing),
"qty_after": new_qty, "qty_after": new_qty,
"avg_entry_after": round(new_avg, 10), "avg_entry_after": round(new_avg, 10),
"loss_at_sl_usdt": round(loss_at_sl, 4), "loss_at_sl_usdt": round(loss_sl, 4),
"reward_at_tp_usdt": round(reward_at_tp, 4), "reward_at_tp_usdt": round(reward_tp, 4),
"legs_done": int(legs_done), "legs_done": int(legs_done),
"leg_index_next": int(legs_done) + 1, "leg_index_next": int(legs_done) + 1,
"fib_upper": fib_upper, "fib_upper": fib_upper,
"fib_lower": fib_lower, "fib_lower": fib_lower,
"contract_size": cs,
}, None }, 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)
+317 -125
View File
@@ -1,17 +1,29 @@
"""滚仓挂单监控:斐波限价止盈侧突破撤单、成交同步、活跃组结案(各所共用)。""" """滚仓程序监控:斐波/突破触价市价成交、失效、外部平仓同步(各所共用)。"""
from __future__ import annotations from __future__ import annotations
from typing import Any, Optional from typing import Any, Optional
from fib_key_monitor_lib import fib_invalidate_by_mark from strategy_roll_lib import (
from strategy_roll_lib import unified_stop_from_avg 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 from strategy_db import init_strategy_tables
ROLL_LEG_STATUS_LABELS = { ROLL_LEG_STATUS_LABELS = {
"pending": "挂单", "pending": "监控",
"filled": "已成交", "filled": "已成交",
"cancelled": "撤销", "cancelled": "删除",
"invalidated": "止盈侧突破失效", "invalidated": "失效",
} }
@@ -40,6 +52,97 @@ def check_roll_monitors(cfg: dict[str, Any]) -> None:
pass 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: def _row_dict(row) -> dict:
if row is None: if row is None:
return {} return {}
@@ -54,25 +157,26 @@ def _now(cfg: dict) -> str:
return fn() if callable(fn) else "" return fn() if callable(fn) else ""
def _close_roll_group( def _cancel_pending_legs_for_group(conn, cfg: dict, group: dict, *, status: str = "cancelled") -> int:
conn,
cfg: dict,
group: dict,
*,
cancel_pending: bool = True,
) -> None:
gid = int(group["id"]) gid = int(group["id"])
if cancel_pending: n = 0
for leg in conn.execute( for leg in conn.execute(
"SELECT * FROM roll_legs WHERE roll_group_id=? AND status='pending'", "SELECT * FROM roll_legs WHERE roll_group_id=? AND status='pending'",
(gid,), (gid,),
).fetchall(): ).fetchall():
ld = _row_dict(leg) ld = _row_dict(leg)
_cancel_roll_leg_order(cfg, group, ld) _cancel_roll_leg_order(cfg, group, ld)
conn.execute( conn.execute(
"UPDATE roll_legs SET status='cancelled' WHERE id=? AND status='pending'", "UPDATE roll_legs SET status=? WHERE id=? AND status='pending'",
(ld["id"],), (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( cur = conn.execute(
"UPDATE roll_groups SET status='closed', updated_at=? WHERE id=? AND status='active'", "UPDATE roll_groups SET status='closed', updated_at=? WHERE id=? AND status='active'",
(_now(cfg), gid), (_now(cfg), gid),
@@ -81,7 +185,6 @@ def _close_roll_group(
try: try:
from strategy_wechat_notify import notify_roll_group_ended from strategy_wechat_notify import notify_roll_group_ended
reason = "下单监控已结案或交易所无同向持仓"
notify_roll_group_ended( notify_roll_group_ended(
cfg, cfg,
group_id=gid, group_id=gid,
@@ -116,7 +219,7 @@ def _reconcile_roll_groups(conn, cfg: dict) -> None:
pos = cfg["get_position"](ex_sym, direction) pos = cfg["get_position"](ex_sym, direction)
qty = float(pos.get("contracts") or 0) qty = float(pos.get("contracts") or 0)
if not mon_ok or qty <= 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: 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 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: def _check_pending_roll_legs(conn, cfg: dict) -> None:
rows = conn.execute( rows = conn.execute(
"""SELECT l.*, g.symbol, g.exchange_symbol, g.direction, g.initial_take_profit, """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 FROM roll_legs l
INNER JOIN roll_groups g ON g.id = l.roll_group_id AND g.status='active' INNER JOIN roll_groups g ON g.id = l.roll_group_id AND g.status='active'
WHERE l.status='pending'""" WHERE l.status='pending'"""
@@ -150,6 +278,8 @@ def _check_pending_roll_legs(conn, cfg: dict) -> None:
"direction": leg["direction"], "direction": leg["direction"],
"initial_take_profit": leg["initial_take_profit"], "initial_take_profit": leg["initial_take_profit"],
"order_monitor_id": leg["order_monitor_id"], "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) _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 "" symbol = group.get("symbol") or ""
direction = (group.get("direction") or "long").strip().lower() direction = (group.get("direction") or "long").strip().lower()
ex_sym = group.get("exchange_symbol") or cfg["normalize_exchange_symbol"](symbol) 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_fn = cfg.get("get_mark_price") or cfg.get("get_price")
mark = mark_fn(symbol) if callable(mark_fn) else None mark = mark_fn(symbol) if callable(mark_fn) else None
if mark is None: if mark is None:
return 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") mode = _resolve_add_mode(leg)
order_st = order_status_fn(ex_sym, oid) if callable(order_status_fn) and oid else "missing" sl = float(leg.get("new_stop_loss") or 0)
fib_u, fib_l = leg.get("fib_upper"), leg.get("fib_lower") 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": if mode in FIB_MODES and fib_u is not None and fib_l is not None:
_finalize_roll_leg_fill(conn, cfg, group, leg, ex_sym, direction, float(mark)) if roll_fib_invalidate(direction, mark_f, float(fib_u), float(fib_l)):
return _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): triggered = False
if order_st == "open": if mode in FIB_MODES:
_cancel_roll_leg_order(cfg, group, leg) lp = leg.get("limit_price")
_invalidate_roll_leg(conn, cfg, group, leg, float(mark)) if lp is not None and roll_fib_trigger_crossed(direction, prev_f, mark_f, float(lp)):
return 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( conn.execute(
"UPDATE roll_legs SET status='invalidated' WHERE id=? AND status='pending'", "UPDATE roll_legs SET last_mark_price=? WHERE id=? AND status='pending'",
(leg_id,), (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, if triggered:
updated_at=? WHERE id=?""", _execute_pending_roll_leg(conn, cfg, group, leg, ex_sym, direction, mark_f)
(_now(cfg), gid), return
)
_send_roll_invalidate_wechat(cfg, group, leg, mark)
def _finalize_roll_leg_fill( def _execute_pending_roll_leg(
conn, conn,
cfg: dict, cfg: dict,
group: dict, group: dict,
@@ -217,94 +342,161 @@ def _finalize_roll_leg_fill(
mark: float, mark: float,
) -> None: ) -> None:
leg_id = int(leg["id"]) leg_id = int(leg["id"])
gid = int(group["id"]) gid = int(group["roll_group_id"]) if "roll_group_id" in leg else 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),
)
mon_id = group.get("order_monitor_id") mon_id = group.get("order_monitor_id")
if mon_id and new_sl > 0: mon = None
conn.execute( if mon_id:
"UPDATE order_monitors SET stop_loss=? WHERE id=? AND status='active'", row = conn.execute("SELECT * FROM order_monitors WHERE id=?", (mon_id,)).fetchone()
(new_sl, mon_id), 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") except Exception as e:
if callable(replace) and new_sl > 0 and tp0 > 0: fe = cfg.get("friendly_error")
mon = None msg = fe(e) if callable(fe) else str(e)
if mon_id: _notify_roll_fail(cfg, group, leg, mark, msg)
row = conn.execute( return
"SELECT * FROM order_monitors WHERE id=?", (mon_id,)
).fetchone() oid = str(order.get("id") or "") if isinstance(order, dict) else ""
mon = _row_dict(row) if row else None cfg["replace_tpsl"](ex_sym, direction, sl, tp0, mon)
try: conn.execute(
replace(ex_sym, direction, new_sl, tp0, mon) """UPDATE roll_legs SET status='filled', fill_price=?, amount=?, exchange_order_id=?,
except Exception: new_stop_loss=? WHERE id=? AND status='pending'""",
pass (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") notify = cfg.get("send_wechat")
if callable(notify): if callable(notify):
sym = group.get("symbol") or "" 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") fmt = cfg.get("format_price")
px_txt = fmt(sym, fill_px) if callable(fmt) else str(fill_px) px_txt = fmt(sym, fill) if callable(fmt) else str(fill)
sl_txt = fmt(sym, new_sl) if callable(fmt) else str(new_sl) sl_txt = fmt(sym, sl) if callable(fmt) else str(sl)
acct = _wechat_account(cfg) acct = _wechat_account(cfg)
dir_txt = _wechat_dir(cfg, direction) dir_txt = _wechat_dir(cfg, direction)
notify( notify(
f"# ✅ {sym} 滚仓限价已成交\n" f"# ✅ {sym} 滚仓触价成交\n"
f"**账户:{acct}**\n" f"**账户:{acct}**\n"
f"- 方式:{mode}{dir_txt}\n" f"- 方式:{mode_lbl}{dir_txt}\n"
f"- 成交价:{px_txt}新止损{sl_txt}\n" f"- 成交价:{px_txt}张数{amount}\n"
f"- 交易所止损已尝试同步(止盈仍为首仓)\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( def _send_roll_invalidate_wechat(
cfg: dict, group: dict, leg: dict, mark: float cfg: dict, group: dict, leg: dict, mark: float, *, reason: str = ""
) -> None: ) -> None:
notify = cfg.get("send_wechat") notify = cfg.get("send_wechat")
if not callable(notify): if not callable(notify):
return return
sym = group.get("symbol") or "" sym = group.get("symbol") or ""
direction = (group.get("direction") or "long").strip().lower() 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") fmt = cfg.get("format_price")
mark_txt = fmt(sym, mark) if callable(fmt) else str(mark) mark_txt = fmt(sym, mark) if callable(fmt) else str(mark)
acct = _wechat_account(cfg) acct = _wechat_account(cfg)
dir_txt = _wechat_dir(cfg, direction) dir_txt = _wechat_dir(cfg, direction)
detail = reason or "条件不满足"
notify( notify(
f"# ⚠️ {sym} 滚仓斐波挂单失效\n" f"# ⚠️ {sym} 滚仓监控失效\n"
f"**账户:{acct}**\n" f"**账户:{acct}**\n"
f"- 方式:{mode}{dir_txt}\n" f"- 方式:{mode}{dir_txt}\n"
f"- 标记价 {mark_txt} 已触达止盈侧(未成交),已撤限价加仓单\n" f"- 标记价 {mark_txt}{detail}\n"
f"- 本条滚仓腿已结案,可继续下一档或重新挂单\n" f"- 本条监控已结案,可重新提交\n"
) )
+3 -108
View File
@@ -5,119 +5,14 @@
<script src="/static/instance_theme.js?v=4"></script> <script src="/static/instance_theme.js?v=4"></script>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>顺势加仓 · {{ exchange_display }}</title> <title>顺势加仓 · {{ exchange_display }}</title>
<style>
body{font-family:system-ui,sans-serif;background:#0f1117;color:#e6e8ef;margin:0;padding:16px}
.container{max-width:1100px;margin:0 auto}
.top-nav{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}
.top-nav a{padding:6px 10px;border:1px solid #304164;border-radius:8px;background:#151a2a;color:#8fc8ff;text-decoration:none}
.top-nav a.active{background:#2a3f6c;color:#dbe4ff}
.card{background:#151a2a;border:1px solid #2a3150;border-radius:10px;padding:14px;margin-bottom:12px}
.form-row{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:8px}
.form-row input,.form-row select{padding:8px 10px;border-radius:6px;border:1px solid #3a4a66;background:#0f1420;color:#eee;min-width:120px}
.form-row button{padding:8px 14px;border-radius:8px;border:none;background:#2d6a4f;color:#fff;cursor:pointer}
.rule-tip{font-size:.8rem;color:#8892b0;line-height:1.5;margin-bottom:10px}
.flash{background:#1f2a44;border:1px solid #3a5a8a;padding:10px;border-radius:8px;margin-bottom:12px}
table{width:100%;border-collapse:collapse;font-size:.82rem}
th,td{border-bottom:1px solid #2a3150;padding:6px 8px;text-align:left}
</style>
<link rel="stylesheet" href="/static/instance_theme.css?v=4"> <link rel="stylesheet" href="/static/instance_theme.css?v=4">
<meta name="theme-color" content="#0b0d14"> <meta name="theme-color" content="#0b0d14">
</head> </head>
<body> <body>
<div class="container"> <div class="container" style="max-width:1100px;margin:0 auto;padding:16px">
<div class="header-row" style="justify-content:space-between;margin-bottom:8px">
<h1 style="margin:0">策略交易 · 顺势加仓 <span style="font-size:.85rem;color:#8fc8ff">{{ exchange_display }}</span></h1>
<div class="theme-toggle instance-theme-toggle" role="group" aria-label="界面主题">
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path fill="currentColor" d="M12.1 3a9 9 0 1 0 8.9 11 6.5 6.5 0 1 1-8.9-11z"/></svg>
</button>
<button type="button" class="theme-toggle-btn" data-theme-value="light" aria-pressed="false" title="亮色主题">
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
</button>
</div>
</div>
<div class="top-nav">
<a href="/trade">实盘下单</a>
<a href="/strategy/trend">趋势回调</a>
<a href="/strategy/roll" class="active">顺势加仓</a>
<a href="/records">交易复盘</a>
</div>
{% with messages = get_flashed_messages() %}{% if messages %}<div class="flash">{{ messages[0] }}</div>{% endif %}{% endwith %} {% with messages = get_flashed_messages() %}{% if messages %}<div class="flash">{{ messages[0] }}</div>{% endif %}{% endwith %}
{% include 'strategy_roll_panel.html' %}
<div class="card"> <p class="rule-tip" style="margin-top:12px"><a href="/strategy/roll/docs" style="color:#8fc8ff">顺势加仓完整逻辑说明</a></p>
<h2 style="margin:0 0 8px">规则说明</h2>
<div class="rule-tip">
<strong>仅人工加仓</strong>,程序不会自动触发。须先在「实盘下单」有同向持仓。<br>
做多最多滚仓 <strong>3</strong> 次;止盈<strong>锁定首仓</strong>不变;每次填写<strong>止损偏移%</strong>(相对合并均价,默认 1%),总风险%按「合并持仓打到新止损≈账户风险」反推张数。<br>
斐波限价:上沿 H、下沿 L 仅用于算 0.618/0.786 加仓价(多:下沿=止损侧;空:上沿=止损侧)。<br>
{% if trend_active %}<span style="color:#ff8f8f">当前有运行中的趋势回调计划,请先结束后再滚仓。</span>{% endif %}
</div>
<form action="{{ url_for('strategy_roll_execute') }}" method="post" class="form-row">
<select name="symbol" required>
<option value="">选择持仓币种</option>
{% for o in monitors %}
<option value="{{ o.symbol }}">{{ o.symbol }} {{ '多' if o.direction=='long' else '空' }} #{{ o.id }}</option>
{% endfor %}
</select>
<select name="direction">
<option value="long">做多</option>
<option value="short">做空</option>
</select>
<select name="add_mode">
<option value="market">市价加仓</option>
<option value="fib_618">限价 斐波0.618</option>
<option value="fib_786">限价 斐波0.786</option>
</select>
<input name="fib_upper" step="any" placeholder="上沿 H">
<input name="fib_lower" step="any" placeholder="下沿 L">
<input name="stop_offset_pct" type="number" min="0.01" step="0.01" value="1" placeholder="止损偏移%(合并均价)" required>
<input name="risk_percent" type="number" min="0.1" step="0.1" value="{{ default_risk_percent }}" placeholder="总风险%">
<button type="submit" {% if trend_active %}disabled{% endif %} onclick="return confirm('确认按预览逻辑实盘加仓并更新止损?')">执行滚仓</button>
</form>
<p class="rule-tip">建议执行前用浏览器开发者工具 POST <code>/strategy/roll/preview</code> 查看 JSON 预览(或将加入页面内预览按钮)。</p>
</div>
<div class="card">
<h3>活跃滚仓组</h3>
<table>
<tr><th>ID</th><th>币种</th><th>方向</th><th>腿数</th><th>首仓TP</th><th>当前SL</th><th>当前均价</th><th>止盈盈利U</th></tr>
{% for g in roll_groups %}
<tr>
<td>{{ g.id }}</td>
<td>{{ g.symbol }}</td>
<td>{{ g.direction }}</td>
<td>{{ g.leg_count }}</td>
<td>{% if price_fmt %}{{ price_fmt(g.symbol, g.initial_take_profit) }}{% else %}{{ g.initial_take_profit }}{% endif %}</td>
<td>{% if price_fmt %}{{ price_fmt(g.symbol, g.current_stop_loss) }}{% else %}{{ g.current_stop_loss }}{% endif %}</td>
<td>{% if g.avg_entry_display %}{{ g.avg_entry_display }}{% elif g.avg_entry is not none %}{{ g.avg_entry }}{% else %}—{% endif %}</td>
<td>{% if g.reward_at_tp_usdt is not none %}{{ '%.2f'|format(g.reward_at_tp_usdt) }}{% else %}—{% endif %}</td>
</tr>
{% else %}
<tr><td colspan="8" style="color:#8892b0">暂无</td></tr>
{% endfor %}
</table>
</div>
<div class="card">
<h3>最近滚仓腿</h3>
<table>
<tr><th>#</th><th></th><th>方式</th><th>张数</th><th>新SL</th><th>当前均价</th><th>止盈盈利U</th><th>状态</th></tr>
{% for leg in roll_legs %}
<tr>
<td>{{ leg.leg_index }}</td>
<td>{{ leg.roll_group_id }}</td>
<td>{{ leg.add_mode }}</td>
<td>{{ leg.amount }}</td>
<td>{{ leg.new_stop_loss }}</td>
<td>{% if leg.avg_entry_display %}{{ leg.avg_entry_display }}{% elif leg.avg_entry_after is not none %}{{ leg.avg_entry_after }}{% else %}—{% endif %}</td>
<td>{% if leg.reward_at_tp_usdt is not none %}{{ '%.2f'|format(leg.reward_at_tp_usdt) }}{% else %}—{% endif %}</td>
<td>{{ leg.status_label or leg.status }}</td>
</tr>
{% else %}
<tr><td colspan="8" style="color:#8892b0">暂无</td></tr>
{% endfor %}
</table>
</div>
</div> </div>
</body> </body>
</html> </html>
@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="zh-CN" data-theme="dark">
<head>
<meta charset="UTF-8">
<script src="/static/instance_theme.js?v=4"></script>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>顺势加仓 · 详细说明 · {{ exchange_display }}</title>
<link rel="stylesheet" href="/static/instance_theme.css?v=4">
<meta name="theme-color" content="#0b0d14">
<style>
body{font-family:system-ui,sans-serif;background:#0f1117;color:#e6e8ef;margin:0;padding:16px}
.container{max-width:920px;margin:0 auto}
.doc-nav{margin-bottom:14px}
.doc-nav a{color:#8fc8ff;text-decoration:none}
.doc-body{background:#151a2a;border:1px solid #2a3150;border-radius:10px;padding:18px 20px;line-height:1.65;font-size:.92rem}
.doc-body h1{font-size:1.35rem;margin:0 0 12px;color:#f0f2ff}
.doc-body h2{font-size:1.08rem;margin:22px 0 10px;color:#b8c4ff;border-bottom:1px solid #2a3150;padding-bottom:6px}
.doc-body h3{font-size:.98rem;margin:16px 0 8px;color:#c9d4ff}
.doc-body p,.doc-body li{color:#dde2ff}
.doc-body ul,.doc-body ol{margin:8px 0 12px 1.25em}
.doc-body code{background:#252538;padding:1px 5px;border-radius:4px;font-size:.88em}
.doc-body pre{background:#0f1420;border:1px solid #2a3150;border-radius:8px;padding:12px;overflow:auto;font-size:.84rem;line-height:1.5}
.doc-body table{width:100%;border-collapse:collapse;margin:10px 0;font-size:.86rem}
.doc-body th,.doc-body td{border:1px solid #2a3150;padding:6px 8px;text-align:left}
.doc-body th{background:#1a2030;color:#b8c4ff}
.doc-body hr{border:none;border-top:1px solid #2a3150;margin:20px 0}
</style>
</head>
<body>
<div class="container">
<div class="doc-nav">
<a href="/strategy">← 返回策略交易</a>
&nbsp;·&nbsp;
<a href="/strategy/roll">顺势加仓</a>
</div>
<article class="doc-body">
{{ doc_html|safe }}
</article>
</div>
</body>
</html>
+52 -29
View File
@@ -1,41 +1,57 @@
<div class="strategy-panel-inner"> <div class="strategy-panel-inner" id="strategy-roll-panel">
<h2 style="margin:0 0 8px">顺势加仓</h2> <h2 style="margin:0 0 8px">顺势加仓</h2>
<details class="tip-collapse strategy-roll-rule-collapse"> <details class="tip-collapse strategy-roll-rule-collapse" open>
<summary class="tip-collapse-summary">顺势加仓规则说明{% if roll_trend_active %} · 当前有趋势回调计划{% endif %}</summary> <summary class="tip-collapse-summary">顺势加仓规则说明{% if roll_trend_active %} · 当前有趋势回调计划{% endif %}</summary>
<div class="tip-collapse-body rule-tip"> <div class="tip-collapse-body rule-tip">
<strong>仅人工加仓</strong>,程序不会自动触发。须先在「实盘下单」有同向持仓。<br> <strong>仅人工提交</strong>须先在「实盘下单」有同向持仓。<strong>以损定仓</strong>模式可用。<br>
做多最多滚仓 <strong>3</strong> 次;止盈<strong>锁定首仓</strong>不变;每次填写<strong>止损偏移%</strong>(相对合并均价,默认 1%),总风险%按「合并持仓打到新止损≈账户风险」反推张数<br> 做多/做空各最多滚仓 <strong>3</strong>(仅计已成交腿);止盈<strong>锁定首仓</strong>不变。<br>
斐波限价:上沿 H、下沿 L 仅用于算 0.618/0.786 加仓价(多:下沿=止损侧;空:上沿=止损侧)。<br> 风险比例读取所选监控单,<strong>不可手改</strong>;打到新止损时合并持仓亏损 ≈ 1 个风险单位(当前基数 × 监控 risk%)。<br>
<strong>仓位上限冻结时仍可顺势加仓</strong>(在已有同向监控持仓上操作,不占用新仓名额)<br> 斐波/突破为<strong>程序监控</strong>(mark 价穿越触发),触价后市价加仓;同时仅允许 <strong>1</strong> 条监控中腿,提交后<strong>不可修改</strong>,可删除<br>
手动平仓后滚仓监控自动结束;<strong>已成交腿历史保留</strong>供复盘。<br>
<a href="/strategy/roll/docs" target="_blank" rel="noopener" style="color:#8fc8ff">→ 顺势加仓完整逻辑说明</a><br>
{% if roll_trend_active %}<span style="color:#ff8f8f">当前有运行中的趋势回调计划,请先结束后再滚仓。</span>{% endif %} {% if roll_trend_active %}<span style="color:#ff8f8f">当前有运行中的趋势回调计划,请先结束后再滚仓。</span>{% endif %}
</div> </div>
</details> </details>
<form action="{{ url_for('strategy_roll_execute') }}" method="post" class="form-row">
<select name="symbol" required> <div id="roll-risk-banner" class="rule-tip" style="margin-bottom:8px;color:#8fc8ff">
当前风险:请选择持仓币种
</div>
<form id="roll-form" action="{{ url_for('strategy_roll_execute') }}" method="post" class="form-row">
<select name="symbol" id="roll-symbol" required>
<option value="">选择持仓币种</option> <option value="">选择持仓币种</option>
{% for o in roll_monitors %} {% for o in roll_monitors %}
<option value="{{ o.symbol }}">{{ o.symbol }} {{ '多' if o.direction=='long' else '空' }} #{{ o.id }}</option> <option value="{{ o.symbol }}"
data-direction="{{ o.direction }}"
data-monitor-id="{{ o.id }}"
data-risk-percent="{{ o.risk_percent or default_risk_percent }}">
{{ o.symbol }} {{ '多' if o.direction=='long' else '空' }} #{{ o.id }} · 风险{{ o.risk_percent or default_risk_percent }}%
</option>
{% endfor %} {% endfor %}
</select> </select>
<select name="direction"> <input type="hidden" name="direction" id="roll-direction" value="long">
<option value="long">做多</option> <select name="add_mode" id="roll-add-mode">
<option value="short">做空</option>
</select>
<select name="add_mode">
<option value="market">市价加仓</option> <option value="market">市价加仓</option>
<option value="fib_618">限价 斐波0.618</option> <option value="fib_618">斐波 0.618</option>
<option value="fib_786">限价 斐波0.786</option> <option value="fib_786">斐波 0.786</option>
<option value="breakout">突破加仓</option>
</select> </select>
<input name="fib_upper" step="any" placeholder="上沿 H"> <span class="roll-field roll-field-fib">
<input name="fib_lower" step="any" placeholder="沿 L"> <input name="fib_upper" id="roll-fib-upper" step="any" placeholder="沿 H">
<input name="stop_offset_pct" type="number" min="0.01" step="0.01" value="1" placeholder="止损偏移%(合并均价)" required> <input name="fib_lower" id="roll-fib-lower" step="any" placeholder="下沿 L">
<input name="risk_percent" type="number" min="0.1" step="0.1" value="{{ default_risk_percent }}" placeholder="总风险%"> </span>
<button type="submit" {% if roll_trend_active %}disabled style="opacity:.5"{% endif %} onclick="return confirm('确认按预览逻辑实盘加仓并更新止损?')">执行滚仓</button> <span class="roll-field roll-field-breakout">
<input name="breakthrough_price" id="roll-breakout" step="any" placeholder="突破价">
</span>
<input name="new_stop_loss" id="roll-stop-loss" type="number" min="0" step="any" placeholder="新止损价" required>
<button type="button" id="roll-preview-btn" {% if roll_trend_active %}disabled{% endif %}>预览</button>
<button type="submit" id="roll-submit-btn" {% if roll_trend_active %}disabled style="opacity:.5"{% endif %} disabled>执行滚仓</button>
</form> </form>
<details class="tip-collapse strategy-roll-preview-tip">
<summary class="tip-collapse-summary">滚仓预览接口说明</summary> <div id="roll-preview-box" class="rule-tip" style="display:none;margin:8px 0;padding:10px;border:1px solid #3a5a8a;border-radius:8px;background:#141a28">
<div class="tip-collapse-body rule-tip">执行前可用开发者工具 POST <code>/strategy/roll/preview</code> 查看 JSON 预览。</div> <div id="roll-preview-text"></div>
</details> <div id="roll-countdown" style="margin-top:6px;color:#ffb347;display:none"></div>
</div>
<h3 style="margin:14px 0 8px;font-size:.95rem;color:#b8c4ff">活跃滚仓组</h3> <h3 style="margin:14px 0 8px;font-size:.95rem;color:#b8c4ff">活跃滚仓组</h3>
<div class="table-wrap"> <div class="table-wrap">
@@ -61,17 +77,23 @@
<h3 style="margin:14px 0 8px;font-size:.95rem;color:#b8c4ff">最近滚仓腿</h3> <h3 style="margin:14px 0 8px;font-size:.95rem;color:#b8c4ff">最近滚仓腿</h3>
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<tr><th>#</th><th></th><th>方式</th><th>张数</th><th>新SL</th><th>当前均价</th><th>止盈盈利U</th><th>状态</th></tr> <tr><th>#</th><th></th><th>方式</th><th>张数</th><th>触发/限价</th><th>新SL</th><th>状态</th><th>操作</th></tr>
{% for leg in roll_legs %} {% for leg in roll_legs %}
<tr> <tr>
<td>{{ leg.leg_index }}</td> <td>{{ leg.leg_index }}</td>
<td>{{ leg.roll_group_id }}</td> <td>{{ leg.roll_group_id }}</td>
<td>{{ leg.add_mode }}</td> <td>{{ leg.add_mode }}</td>
<td>{{ leg.amount }}</td> <td>{% if leg.amount %}{{ leg.amount }}{% else %}—{% endif %}</td>
<td>{% if leg.limit_price %}{{ leg.limit_price }}{% elif leg.breakthrough_price %}{{ leg.breakthrough_price }}{% elif leg.fill_price %}{{ leg.fill_price }}{% else %}—{% endif %}</td>
<td>{{ leg.new_stop_loss }}</td> <td>{{ leg.new_stop_loss }}</td>
<td>{% if leg.avg_entry_display %}{{ leg.avg_entry_display }}{% elif leg.avg_entry_after is not none %}{{ leg.avg_entry_after }}{% else %}—{% endif %}</td>
<td>{% if leg.reward_at_tp_usdt is not none %}{{ '%.2f'|format(leg.reward_at_tp_usdt) }}{% else %}—{% endif %}</td>
<td>{{ leg.status_label or leg.status }}</td> <td>{{ leg.status_label or leg.status }}</td>
<td>
{% if leg.status == 'pending' %}
<form action="{{ url_for('strategy_roll_cancel_leg', leg_id=leg.id) }}" method="post" style="margin:0" onsubmit="return confirm('确认删除本条滚仓监控?')">
<button type="submit" style="padding:2px 8px;font-size:.75rem">删除</button>
</form>
{% else %}—{% endif %}
</td>
</tr> </tr>
{% else %} {% else %}
<tr><td colspan="8" style="color:#8892b0">暂无</td></tr> <tr><td colspan="8" style="color:#8892b0">暂无</td></tr>
@@ -79,3 +101,4 @@
</table> </table>
</div> </div>
</div> </div>
<script src="/static/strategy_roll.js?v=1"></script>
+46 -35
View File
@@ -1,29 +1,24 @@
from strategy_roll_lib import ( from strategy_roll_lib import (
preview_roll, preview_roll,
resolve_roll_stop_spec, roll_breakout_invalidate,
roll_stop_after_fill, roll_breakout_trigger_crossed,
unified_stop_from_avg, 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(): def test_solve_add_amount_long_one_risk():
mode, val = resolve_roll_stop_spec(new_stop_loss=1.0, entry_ref=63.976) q2, err = solve_add_amount_for_total_risk(
assert mode == "offset" "long", 1.0, 3000.0, 3100.0, 2950.0, 200.0, 1.0
assert val == 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(): def test_preview_roll_market_short():
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():
preview, err = preview_roll( preview, err = preview_roll(
direction="short", direction="short",
symbol="HYPE/USDT", symbol="HYPE/USDT",
@@ -31,29 +26,45 @@ def test_preview_roll_offset_mode_not_breakeven():
entry_existing=65.0, entry_existing=65.0,
initial_take_profit=60.0, initial_take_profit=60.0,
add_mode="market", add_mode="market",
stop_offset_pct=1.0, new_stop_loss=66.5,
risk_percent=2.0, risk_percent=2.0,
capital_base_usdt=1000.0, capital_base_usdt=1000.0,
add_price=64.0, add_price=64.0,
legs_done=1, legs_done=1,
) )
assert err is None assert err is None
assert preview["stop_mode"] == "offset" assert preview["add_mode_label"] == "市价加仓"
assert preview["stop_offset_pct"] == 1.0
avg = preview["avg_entry_after"]
sl = preview["new_stop_loss"] sl = preview["new_stop_loss"]
assert sl > avg * 1.009 avg = preview["avg_entry_after"]
assert sl < avg * 1.011 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(): def test_fib_cross_long_down():
sl = roll_stop_after_fill( assert roll_fib_trigger_crossed("long", 101.0, 100.0, 100.5) is True
"short", assert roll_fib_trigger_crossed("long", 100.6, 100.6, 100.5) is False
qty_before=3.0,
entry_before=65.0,
add_qty=5.0, def test_breakout_cross_long_up():
fill_price=63.5, assert roll_breakout_trigger_crossed("long", 99.0, 100.5, 100.0) is True
stop_offset_pct=1.0, 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 err is None
assert abs(sl - avg * 1.01) < 1e-6 assert preview["add_mode_label"] == "突破加仓"
+2
View File
@@ -57,6 +57,8 @@ strategy_records_register.py # /strategy/records 路由与列表数据
## 四、顺势加仓(滚仓,仅人工) ## 四、顺势加仓(滚仓,仅人工)
> **详细说明**(计仓公式、四种方式、程序监控、生命周期):仓库 [`顺势加仓滚仓说明.md`](./顺势加仓滚仓说明.md);各实例策略页 **[`/strategy/roll/docs`](/strategy/roll/docs)** 可在线阅读。
### 4.1 原则 ### 4.1 原则
- **禁止自动加仓**;仅页面按钮「执行滚仓」或挂限价单(无价格穿越自动下单)。 - **禁止自动加仓**;仅页面按钮「执行滚仓」或挂限价单(无价格穿越自动下单)。
+174
View File
@@ -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**<br>• **多**mark **向下穿越** P → 市价加<br>• **空**mark **向上穿越** P → 市价加 |
| 失效 | **止盈侧**:多 mark≥H;空 mark≤L |
| 显示 | `斐波0.618` / `斐波0.786` |
### 4.3 突破加仓
| 输入 | **突破价 B**、新止损 S |
| 触发 | 程序监控 **mark**<br>• **多**mark **向上穿越** B → 市价加<br>• **空**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 closedpending 清除
```
| 规则 | 说明 |
|------|------|
| 提交后不可改 | pending 腿参数不可编辑,只能 **删除** |
| 手动平仓 | 实例页删单/平仓、中控持仓平仓 → 调用 `roll_sync_after_external_close` |
| 历史保留 | **filled** 腿写入库与策略复盘快照;关组后 pending 清除,已成交腿仍可在「策略交易记录」中查看 |
API
- `POST /strategy/roll/preview` — JSON 预览
- `POST /strategy/roll/execute` — 提交市价或监控计划
- `POST /strategy/roll/cancel/<leg_id>` — 删除 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 **不可改、可删**;手动平仓自动结束滚仓监控