顺势加仓 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(),
)
conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, id))
try:
_rcfg = app.extensions.get("strategy_roll_cfg")
if isinstance(_rcfg, dict):
from strategy_register import roll_sync_after_external_close
roll_sync_after_external_close(_rcfg, conn, row["symbol"], row["direction"])
except Exception:
pass
clear_key_sizing_snapshot_if_flat(conn, session_date)
conn.commit()
conn.close()
@@ -8740,6 +8748,14 @@ def del_order(id):
now=app_now(),
)
conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (id,))
try:
_rcfg = app.extensions.get("strategy_roll_cfg")
if isinstance(_rcfg, dict):
from strategy_register import roll_sync_after_external_close
roll_sync_after_external_close(_rcfg, conn, row["symbol"], row["direction"])
except Exception:
pass
conn.commit()
conn.close()
flash("该仓位在交易所已不存在,已按成交记录同步结束并记账")
+16
View File
@@ -8602,6 +8602,14 @@ def del_order(id):
now=app_now(),
)
conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, id))
try:
_rcfg = app.extensions.get("strategy_roll_cfg")
if isinstance(_rcfg, dict):
from strategy_register import roll_sync_after_external_close
roll_sync_after_external_close(_rcfg, conn, row["symbol"], row["direction"])
except Exception:
pass
clear_key_sizing_snapshot_if_flat(conn, session_date)
conn.commit()
conn.close()
@@ -8670,6 +8678,14 @@ def del_order(id):
now=app_now(),
)
conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (id,))
try:
_rcfg = app.extensions.get("strategy_roll_cfg")
if isinstance(_rcfg, dict):
from strategy_register import roll_sync_after_external_close
roll_sync_after_external_close(_rcfg, conn, row["symbol"], row["direction"])
except Exception:
pass
conn.commit()
conn.close()
flash("该仓位在交易所已不存在,已按成交记录同步结束并记账")
+16
View File
@@ -8598,6 +8598,14 @@ def del_order(id):
now=app_now(),
)
conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, id))
try:
_rcfg = app.extensions.get("strategy_roll_cfg")
if isinstance(_rcfg, dict):
from strategy_register import roll_sync_after_external_close
roll_sync_after_external_close(_rcfg, conn, row["symbol"], row["direction"])
except Exception:
pass
clear_key_sizing_snapshot_if_flat(conn, session_date)
conn.commit()
conn.close()
@@ -8666,6 +8674,14 @@ def del_order(id):
now=app_now(),
)
conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (id,))
try:
_rcfg = app.extensions.get("strategy_roll_cfg")
if isinstance(_rcfg, dict):
from strategy_register import roll_sync_after_external_close
roll_sync_after_external_close(_rcfg, conn, row["symbol"], row["direction"])
except Exception:
pass
conn.commit()
conn.close()
flash("该仓位在交易所已不存在,已按成交记录同步结束并记账")
+16
View File
@@ -8092,6 +8092,14 @@ def del_order(id):
now=app_now(),
)
conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, id))
try:
_rcfg = app.extensions.get("strategy_roll_cfg")
if isinstance(_rcfg, dict):
from strategy_register import roll_sync_after_external_close
roll_sync_after_external_close(_rcfg, conn, row["symbol"], row["direction"])
except Exception:
pass
conn.commit()
conn.close()
send_wechat_msg(
@@ -8157,6 +8165,14 @@ def del_order(id):
now=app_now(),
)
conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (id,))
try:
_rcfg = app.extensions.get("strategy_roll_cfg")
if isinstance(_rcfg, dict):
from strategy_register import roll_sync_after_external_close
roll_sync_after_external_close(_rcfg, conn, row["symbol"], row["direction"])
except Exception:
pass
conn.commit()
conn.close()
flash("该仓位在交易所已不存在,已按成交记录同步结束并记账")
+32
View File
@@ -791,6 +791,38 @@ def register_hub_routes(app):
finally:
conn.close()
@app.route("/api/hub/roll/sync-flat", methods=["POST"])
@_hub_auth_required
def api_hub_roll_sync_flat():
"""中控/实例手动平仓后:取消滚仓 pending 并关闭 active 滚仓组。"""
body = request.get_json(silent=True) or {}
symbol = (body.get("symbol") or request.form.get("symbol") or "").strip()
side = (
body.get("side")
or body.get("direction")
or request.form.get("side")
or ""
).strip().lower()
if not symbol:
return jsonify({"ok": False, "msg": "symbol 不能为空"}), 400
if side not in ("long", "short"):
return jsonify({"ok": False, "msg": "side 须为 long 或 short"}), 400
cfg = current_app.extensions.get("strategy_roll_cfg")
get_db = _ctx().get("get_db")
if not cfg or not callable(get_db):
return jsonify({"ok": False, "msg": "滚仓配置未就绪"}), 500
from strategy_register import roll_sync_after_external_close
conn = get_db()
try:
out = roll_sync_after_external_close(cfg, conn, symbol, side)
conn.commit()
return jsonify(out)
except Exception as e:
return jsonify({"ok": False, "msg": str(e)}), 500
finally:
conn.close()
@app.route("/api/hub/trend/breakeven/<int:pid>", methods=["POST"])
@_hub_auth_required
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):
out["trend_sync"] = sync_parsed
roll_sync = await _fetch_flask_json(
flask_client,
ex,
"/api/hub/roll/sync-flat",
method="POST",
json_body={"symbol": sym, "side": side},
)
if isinstance(roll_sync, dict):
out["roll_sync"] = roll_sync
risk_sync = await _notify_instance_user_close(flask_client, ex, count=1)
if isinstance(risk_sync, dict):
out["risk_sync"] = risk_sync
+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 trend_pullback_plans ADD COLUMN leg_fill_prices_json TEXT",
"ALTER TABLE roll_legs ADD COLUMN stop_offset_pct REAL",
"ALTER TABLE roll_legs ADD COLUMN breakthrough_price REAL",
"ALTER TABLE roll_legs ADD COLUMN last_mark_price REAL",
):
try:
conn.execute(ddl)
+336 -162
View File
@@ -1,15 +1,22 @@
"""策略交易:Flask 路由注册(顺势加仓 + 趋势回调页)。逻辑在 strategy_*_lib。"""
from __future__ import annotations
import html as html_module
import os
from functools import wraps
from typing import Any, Callable, Optional
import re
from typing import Any, Optional
from flask import Flask, flash, jsonify, redirect, request, url_for
from flask import Flask, flash, jsonify, redirect, render_template, request, url_for
from jinja2 import ChoiceLoader, FileSystemLoader
from strategy_db import init_strategy_tables
from strategy_roll_lib import preview_roll, roll_stop_after_fill
from strategy_roll_lib import BREAKOUT_MODE, FIB_MODES, MARKET_MODE, preview_roll
from strategy_roll_monitor_lib import (
cancel_roll_pending_leg,
count_filled_roll_legs,
count_pending_roll_legs,
sync_roll_after_external_close,
)
def _dedupe_strategy_snapshots_on_startup(cfg: dict[str, Any]) -> None:
@@ -68,7 +75,6 @@ def register_strategy_trading(app: Flask, cfg: dict[str, Any]) -> None:
"""cfg 由各市面 app 注入回调(仅 API / DB 差异)。"""
login_required = cfg["login_required"]
get_db = cfg["get_db"]
def _lr(f):
return login_required(f)
@@ -81,10 +87,11 @@ def register_strategy_trading(app: Flask, cfg: dict[str, Any]) -> None:
if request.is_json:
return jsonify(err)
if err.get("ok"):
p = err["preview"]
flash(
f"预览:加仓{err['preview'].get('add_amount_display', '-')} 张,"
f"合并均价 {err['preview'].get('avg_entry_after', '-')}"
f"触及新止损亏损约 {err['preview'].get('loss_at_sl_usdt', '-')}U"
f"预览:约 {p.get('add_amount_display', '-')} 张,"
f"合并均价 {p.get('avg_entry_after', '-')}"
f"打到止损约 {p.get('loss_at_sl_usdt', '-')}U"
)
else:
flash(err.get("msg") or "预览失败")
@@ -103,7 +110,109 @@ def register_strategy_trading(app: Flask, cfg: dict[str, Any]) -> None:
flash(msg)
return redirect(url_for("strategy_trading_page"))
# 趋势回调:仍由各市面 app 注册原有路由;导航指向 /strategy/trend
@_lr
@app.route("/strategy/roll/cancel/<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:
@@ -129,8 +238,27 @@ def _count_active_trends(conn, cfg: dict) -> int:
return 0
def _roll_preview_response(cfg: dict, data: dict, json_mode: bool = False) -> dict:
"""顺势加仓不占用 MAX_ACTIVE_POSITIONS 新仓名额,故不校验仓位上限冻结。"""
def _risk_from_monitor(mon: dict, cfg: dict) -> tuple[Optional[float], Optional[str]]:
try:
rp = float(mon.get("risk_percent") or cfg.get("default_risk_percent", 2))
except (TypeError, ValueError):
return None, "监控单风险%无效"
if rp <= 0:
return None, "监控单风险%须大于0"
return rp, None
def _contract_size(cfg: dict, ex_sym: str) -> float:
get_cs = cfg.get("get_contract_size")
if callable(get_cs):
try:
return float(get_cs(ex_sym) or 1.0)
except Exception:
pass
return 1.0
def _roll_context(cfg: dict, data: dict) -> tuple[Optional[dict], Optional[str]]:
m = cfg.get("app_module")
if m is not None:
try:
@@ -139,99 +267,153 @@ def _roll_preview_response(cfg: dict, data: dict, json_mode: bool = False) -> di
mode = getattr(m, "POSITION_SIZING_MODE", None) or "risk"
ok_src, src_msg = assert_open_source_allowed(mode, OPEN_SOURCE_ROLL)
if not ok_src:
return {"ok": False, "msg": src_msg}
return None, src_msg
except Exception:
pass
get_db = cfg["get_db"]
symbol = cfg["normalize_symbol_input"](data.get("symbol") or "")
if not symbol:
return {"ok": False, "msg": "请选择或填写币种"}
return None, "请选择或填写币种"
direction = (data.get("direction") or "long").strip().lower()
ex_sym = cfg["normalize_exchange_symbol"](symbol)
conn = get_db()
init_strategy_tables(conn)
if _count_active_trends(conn, cfg) > 0:
conn.close()
return {"ok": False, "msg": "存在运行中的趋势回调计划,请先结束后再滚仓"}
return None, "存在运行中的趋势回调计划,请先结束后再滚仓"
mon = _get_active_monitor(conn, cfg, symbol, direction)
if not mon:
conn.close()
return {"ok": False, "msg": "未找到该币种同向的下单监控持仓,请先在「实盘下单」开仓"}
rg, legs_done, _is_new = _get_or_create_roll_group_meta(conn, mon)
return None, "未找到该币种同向的下单监控持仓,请先在「实盘下单」开仓"
rg, legs_done, pending, roll_is_new = _get_or_create_roll_group_meta(conn, mon)
if pending > 0:
conn.close()
pos = cfg["get_position"](ex_sym, direction)
qty = float(pos.get("contracts") or 0)
if qty <= 0:
return {"ok": False, "msg": "交易所无该方向持仓,无法滚仓"}
entry = float(pos.get("entry_price") or mon.get("trigger_price") or 0)
if entry <= 0:
return {"ok": False, "msg": "无法获取持仓均价"}
tp0 = float(mon.get("take_profit") or rg.get("initial_take_profit") or 0)
add_mode = (data.get("add_mode") or "market").strip().lower()
try:
risk_pct = float(data.get("risk_percent") or cfg.get("default_risk_percent", 2))
except (TypeError, ValueError):
return {"ok": False, "msg": "风险%格式错误"}
stop_offset_raw = data.get("stop_offset_pct")
if stop_offset_raw in (None, ""):
stop_offset_raw = data.get("new_stop_loss") or data.get("sl")
new_sl_abs = None
stop_offset_pct = None
if data.get("stop_offset_pct") not in (None, ""):
try:
stop_offset_pct = float(data.get("stop_offset_pct"))
except (TypeError, ValueError):
return {"ok": False, "msg": "止损偏移%格式错误"}
elif data.get("new_stop_loss") not in (None, "") or data.get("sl") not in (None, ""):
try:
new_sl_abs = float(data.get("new_stop_loss") or data.get("sl"))
except (TypeError, ValueError):
return {"ok": False, "msg": "止损格式错误"}
elif stop_offset_raw not in (None, ""):
try:
new_sl_abs = float(stop_offset_raw)
except (TypeError, ValueError):
return {"ok": False, "msg": "止损格式错误"}
return None, "已有监控中的滚仓腿,请等待成交/失效或先删除后再提交"
conn_cap = get_db()
try:
capital = float(cfg["get_trading_capital_usdt"](conn_cap))
finally:
conn_cap.close()
live = cfg["get_price"](symbol)
fib_u = fib_l = None
risk_pct, risk_err = _risk_from_monitor(mon, cfg)
if risk_err:
conn.close()
return None, risk_err
pos = cfg["get_position"](ex_sym, direction)
qty = float(pos.get("contracts") or 0)
if qty <= 0:
conn.close()
return None, "交易所无该方向持仓,无法滚仓"
entry = float(pos.get("entry_price") or mon.get("trigger_price") or 0)
if entry <= 0:
conn.close()
return None, "无法获取持仓均价"
mark_fn = cfg.get("get_mark_price") or cfg.get("get_price")
mark = mark_fn(symbol) if callable(mark_fn) else cfg["get_price"](symbol)
ctx = {
"conn": conn,
"mon": mon,
"rg": rg,
"legs_done": legs_done,
"symbol": symbol,
"direction": direction,
"ex_sym": ex_sym,
"qty": qty,
"entry": entry,
"mark": float(mark) if mark else None,
"capital": capital,
"risk_pct": float(risk_pct),
"tp0": float(mon.get("take_profit") or rg.get("initial_take_profit") or 0),
"contract_size": _contract_size(cfg, ex_sym),
}
return ctx, None
def _parse_roll_form(data: dict, ctx: dict) -> tuple[Optional[dict], Optional[str]]:
add_mode = (data.get("add_mode") or MARKET_MODE).strip().lower()
raw_sl = data.get("new_stop_loss") or data.get("sl")
if raw_sl in (None, ""):
return None, "请填写新止损价"
try:
new_sl = float(raw_sl)
except (TypeError, ValueError):
return None, "止损价格式错误"
if new_sl <= 0:
return None, "止损价须大于0"
fib_u = fib_l = bp = None
try:
if data.get("fib_upper") not in (None, ""):
fib_u = float(data.get("fib_upper"))
if data.get("fib_lower") not in (None, ""):
fib_l = float(data.get("fib_lower"))
if data.get("breakthrough_price") not in (None, ""):
bp = float(data.get("breakthrough_price"))
except (TypeError, ValueError):
return {"ok": False, "msg": "斐波上沿/下沿格式错误"}
preview, err = preview_roll(
direction=direction,
symbol=symbol,
qty_existing=qty,
entry_existing=entry,
initial_take_profit=tp0,
add_mode=add_mode,
new_stop_loss=new_sl_abs,
stop_offset_pct=stop_offset_pct,
risk_percent=risk_pct,
capital_base_usdt=capital,
add_price=float(live) if live else None,
fib_upper=fib_u,
fib_lower=fib_l,
legs_done=legs_done,
)
return None, "价格参数格式错误"
add_price = ctx.get("mark")
if add_mode == MARKET_MODE:
if add_price is None or add_price <= 0:
return None, "无法获取市价快照"
elif add_mode in FIB_MODES:
if fib_u is None or fib_l is None:
return None, "斐波须填写上沿 H 与下沿 L"
elif add_mode == BREAKOUT_MODE:
if bp is None:
return None, "突破加仓须填写突破价"
add_price = ctx.get("mark")
else:
return None, "加仓方式无效"
return {
"add_mode": add_mode,
"new_stop_loss": new_sl,
"fib_upper": fib_u,
"fib_lower": fib_l,
"breakthrough_price": bp,
"add_price": add_price,
}, None
def _roll_preview_response(cfg: dict, data: dict, json_mode: bool = False) -> dict:
ctx, err = _roll_context(cfg, data)
if err:
return {"ok": False, "msg": err}
parsed, perr = _parse_roll_form(data, ctx)
if perr:
ctx["conn"].close()
return {"ok": False, "msg": perr}
conn = ctx["conn"]
try:
preview, perr2 = preview_roll(
direction=ctx["direction"],
symbol=ctx["symbol"],
qty_existing=ctx["qty"],
entry_existing=ctx["entry"],
initial_take_profit=ctx["tp0"],
add_mode=parsed["add_mode"],
new_stop_loss=parsed["new_stop_loss"],
risk_percent=ctx["risk_pct"],
capital_base_usdt=ctx["capital"],
add_price=parsed["add_price"],
fib_upper=parsed["fib_upper"],
fib_lower=parsed["fib_lower"],
breakthrough_price=parsed["breakthrough_price"],
legs_done=ctx["legs_done"],
contract_size=ctx["contract_size"],
)
finally:
conn.close()
if perr2:
return {"ok": False, "msg": perr2}
amt_raw = float(preview["add_amount_raw"])
amt_p = cfg["amount_to_precision"](ex_sym, amt_raw)
amt_p = cfg["amount_to_precision"](ctx["ex_sym"], amt_raw)
preview["add_amount_display"] = amt_p if amt_p is not None else amt_raw
preview["risk_display"] = f"{ctx['risk_pct']:g}%≈{ctx['capital'] * ctx['risk_pct'] / 100:.2f}U"
price_fmt = cfg.get("price_fmt")
if callable(price_fmt):
preview["add_price_display"] = price_fmt(symbol, preview["add_price"])
preview["new_sl_display"] = price_fmt(symbol, preview["new_stop_loss"])
preview["tp_display"] = price_fmt(symbol, preview["initial_take_profit"])
preview["add_price_display"] = price_fmt(ctx["symbol"], preview["add_price"])
preview["new_sl_display"] = price_fmt(ctx["symbol"], preview["new_stop_loss"])
preview["tp_display"] = price_fmt(ctx["symbol"], preview["initial_take_profit"])
return {"ok": True, "preview": preview}
@@ -250,9 +432,8 @@ def _roll_execute(cfg: dict, data: dict) -> tuple[bool, str]:
direction = preview["direction"]
ex_sym = cfg["normalize_exchange_symbol"](symbol)
add_mode = preview["add_mode"]
amount = cfg["amount_to_precision"](ex_sym, float(preview["add_amount_raw"]))
if amount is None or amount <= 0:
return False, "加仓张数低于交易所最小精度"
new_sl = float(preview["new_stop_loss"])
tp0 = float(preview["initial_take_profit"])
lev_fn = cfg.get("default_leverage")
if not callable(lev_fn):
lev_fn = lambda _s: 5
@@ -262,79 +443,27 @@ def _roll_execute(cfg: dict, data: dict) -> tuple[bool, str]:
mon = _get_active_monitor(conn, cfg, symbol, direction)
if not mon:
return False, "监控单已不存在"
rg, legs_done, roll_is_new = _get_or_create_roll_group_meta(conn, mon)
new_sl = float(preview["new_stop_loss"])
stop_offset_pct = preview.get("stop_offset_pct")
tp0 = float(preview["initial_take_profit"])
qty_before = float(preview.get("qty_existing") or 0)
entry_before = float(preview.get("entry_existing") or 0)
if add_mode == "market":
rg, legs_done, pending, roll_is_new = _get_or_create_roll_group_meta(conn, mon)
if pending > 0:
return False, "已有监控中的滚仓腿,请先删除或等待结束"
if add_mode == MARKET_MODE:
amount = cfg["amount_to_precision"](ex_sym, float(preview["add_amount_raw"]))
if amount is None or amount <= 0:
return False, "加仓张数低于交易所最小精度"
order = cfg["market_add"](ex_sym, direction, amount, leverage)
fill = float(cfg.get("resolve_fill_price", lambda o, s, p: p)(order, ex_sym, preview["add_price"]) or preview["add_price"])
status = "filled"
fill = float(
cfg.get("resolve_fill_price", lambda o, s, p: p)(
order, ex_sym, preview["add_price"]
)
or preview["add_price"]
)
oid = str(order.get("id") or "") if isinstance(order, dict) else ""
if stop_offset_pct is not None and qty_before > 0 and entry_before > 0:
new_sl = roll_stop_after_fill(
direction,
qty_before,
entry_before,
float(amount),
fill,
stop_offset_pct=float(stop_offset_pct),
)
px_fn = cfg.get("price_to_precision")
if callable(px_fn):
new_sl = float(px_fn(ex_sym, new_sl) or new_sl)
else:
price = cfg["price_to_precision"](ex_sym, float(preview["add_price"]))
order = cfg["limit_add"](ex_sym, direction, amount, price, leverage)
oid = str(order.get("id") or "") if isinstance(order, dict) else ""
conn.execute(
"""INSERT INTO roll_legs (
roll_group_id, leg_index, add_mode, fib_upper, fib_lower, limit_price,
amount, new_stop_loss, stop_offset_pct, exchange_order_id, status, created_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""",
(
rg["id"],
legs_done + 1,
preview["add_mode_label"],
preview.get("fib_upper"),
preview.get("fib_lower"),
price,
amount,
new_sl,
stop_offset_pct,
oid,
"pending",
cfg["app_now_str"](),
),
)
conn.execute(
"UPDATE roll_groups SET leg_count=?, updated_at=? WHERE id=?",
(legs_done + 1, cfg["app_now_str"](), rg["id"]),
)
conn.commit()
if roll_is_new:
try:
from strategy_wechat_notify import notify_roll_group_started
notify_roll_group_started(
cfg,
group_id=int(rg["id"]),
symbol=symbol,
direction=direction,
order_monitor_id=int(mon["id"]),
initial_take_profit=tp0,
initial_stop_loss=float(mon.get("stop_loss") or new_sl),
)
except Exception:
pass
return True, f"已挂限价加仓单 #{oid},成交后请在页面点「同步持仓并更新止损」"
cfg["replace_tpsl"](ex_sym, direction, new_sl, tp0, mon)
conn.execute(
"""INSERT INTO roll_legs (
roll_group_id, leg_index, add_mode, fib_upper, fib_lower, limit_price,
fill_price, amount, new_stop_loss, stop_offset_pct, exchange_order_id, status, created_at
breakthrough_price, fill_price, amount, new_stop_loss, exchange_order_id,
status, created_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
rg["id"],
@@ -343,10 +472,10 @@ def _roll_execute(cfg: dict, data: dict) -> tuple[bool, str]:
preview.get("fib_upper"),
preview.get("fib_lower"),
None,
preview.get("breakthrough_price"),
fill,
amount,
new_sl,
stop_offset_pct,
oid,
"filled",
cfg["app_now_str"](),
@@ -361,24 +490,39 @@ def _roll_execute(cfg: dict, data: dict) -> tuple[bool, str]:
(new_sl, mon["id"]),
)
conn.commit()
try:
from strategy_wechat_notify import (
notify_roll_group_started,
_maybe_notify_roll_started(cfg, rg, mon, symbol, direction, tp0, new_sl, roll_is_new=roll_is_new)
return True, f"市价加仓第 {legs_done + 1} 腿已成交,止损已更新,止盈仍为首仓"
# 程序监控:斐波 / 突破
limit_px = None
if add_mode in FIB_MODES:
px_fn = cfg.get("price_to_precision")
limit_px = float(preview["add_price"])
if callable(px_fn):
limit_px = float(px_fn(ex_sym, limit_px) or limit_px)
mark_fn = cfg.get("get_mark_price") or cfg.get("get_price")
last_mark = mark_fn(symbol) if callable(mark_fn) else preview["add_price"]
conn.execute(
"""INSERT INTO roll_legs (
roll_group_id, leg_index, add_mode, fib_upper, fib_lower, limit_price,
breakthrough_price, new_stop_loss, last_mark_price, status, created_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
(
rg["id"],
legs_done + 1,
preview["add_mode_label"],
preview.get("fib_upper"),
preview.get("fib_lower"),
limit_px,
preview.get("breakthrough_price"),
new_sl,
last_mark,
"pending",
cfg["app_now_str"](),
),
)
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}"
conn.commit()
_maybe_notify_roll_started(cfg, rg, mon, symbol, direction, tp0, new_sl, roll_is_new=roll_is_new)
return True, f"已提交{preview['add_mode_label']}监控,触价后将市价加仓并更新止损"
except Exception as e:
fe = cfg.get("friendly_error")
return False, fe(e) if callable(fe) else str(e)
@@ -390,6 +534,25 @@ def _roll_execute(cfg: dict, data: dict) -> tuple[bool, str]:
pass
def _maybe_notify_roll_started(cfg, rg, mon, symbol, direction, tp0, new_sl, *, roll_is_new: bool) -> None:
if not roll_is_new:
return
try:
from strategy_wechat_notify import notify_roll_group_started
notify_roll_group_started(
cfg,
group_id=int(rg["id"]),
symbol=symbol,
direction=direction,
order_monitor_id=int(mon["id"]),
initial_take_profit=tp0,
initial_stop_loss=float(mon.get("stop_loss") or new_sl),
)
except Exception:
pass
def _get_active_monitor(conn, cfg: dict, symbol: str, direction: str) -> Optional[dict]:
row = conn.execute(
"SELECT * FROM order_monitors WHERE status='active' AND symbol=? AND direction=? ORDER BY id DESC LIMIT 1",
@@ -398,14 +561,18 @@ def _get_active_monitor(conn, cfg: dict, symbol: str, direction: str) -> Optiona
return _row_to_dict(row) if row else None
def _get_or_create_roll_group_meta(conn, mon: dict) -> tuple[dict, int, bool]:
def _get_or_create_roll_group_meta(conn, mon: dict) -> tuple[dict, int, int, bool]:
"""返回 (roll_group, filled_legs, pending_legs, is_new_group)。"""
row = conn.execute(
"SELECT * FROM roll_groups WHERE order_monitor_id=? AND status='active' ORDER BY id DESC LIMIT 1",
(mon["id"],),
).fetchone()
if row:
d = _row_to_dict(row)
return d, int(d.get("leg_count") or 0), False
gid = int(d["id"])
filled = count_filled_roll_legs(conn, gid)
pending = count_pending_roll_legs(conn, gid)
return d, filled, pending, False
now = mon.get("created_at") or ""
cur = conn.execute(
"""INSERT INTO roll_groups (
@@ -439,7 +606,14 @@ def _get_or_create_roll_group_meta(conn, mon: dict) -> tuple[dict, int, bool]:
"direction": mon.get("direction"),
},
0,
0,
True,
)
def roll_sync_after_external_close(cfg: dict, conn, symbol: str, direction: str) -> dict:
"""供 hub / del_order 调用的滚仓同步入口。"""
return sync_roll_after_external_close(
cfg, conn, symbol, direction, reason="手动平仓,滚仓监控已结束"
)
+245 -170
View File
@@ -1,14 +1,23 @@
"""顺势加仓(滚仓):纯计算。人工触发;止盈锁定首仓;斐波仅算限价"""
"""顺势加仓(滚仓):纯计算。人工触发;止盈锁定首仓;程序监控触价市价成交"""
from __future__ import annotations
from typing import Any, Optional, Tuple
from fib_key_monitor_lib import calc_fib_plan, fib_ratio_from_type
from fib_key_monitor_lib import calc_fib_plan, fib_invalidate_by_mark
ROLL_MAX_LEGS_LONG = 3
ROLL_MAX_LEGS_SHORT = 3
ROLL_STOP_OFFSET_PCT_DEFAULT = 1.0
MARKET_MODE = "market"
FIB_MODES = frozenset({"fib_618", "fib_786"})
BREAKOUT_MODE = "breakout"
MODE_LABELS = {
MARKET_MODE: "市价加仓",
"fib_618": "斐波0.618",
"fib_786": "斐波0.786",
BREAKOUT_MODE: "突破加仓",
}
def fib_ratio_from_mode(mode: str) -> Optional[float]:
@@ -20,6 +29,11 @@ def fib_ratio_from_mode(mode: str) -> Optional[float]:
return None
def mode_label(mode: str) -> str:
m = (mode or MARKET_MODE).strip().lower()
return MODE_LABELS.get(m, m)
def fib_limit_entry(direction: str, upper: float, lower: float, mode: str) -> Tuple[Optional[float], Optional[str]]:
"""H/L 仅用于计算限价加仓价;多:下沿=止损侧;空:上沿=止损侧。"""
ratio = fib_ratio_from_mode(mode)
@@ -43,49 +57,6 @@ def max_roll_legs(direction: str) -> int:
return ROLL_MAX_LEGS_LONG if (direction or "long").strip().lower() == "long" else ROLL_MAX_LEGS_SHORT
def resolve_roll_stop_spec(
*,
new_stop_loss: Optional[float] = None,
stop_offset_pct: Optional[float] = None,
entry_ref: float = 0.0,
) -> tuple[str, float]:
"""
解析滚仓止损输入
- stop_offset_pct相对合并均价的偏移% 1 表示 1%均价下方均价上方
- new_stop_loss兼容旧版绝对止损价若数值很小 1.0且相对均价过低视为偏移%
"""
if stop_offset_pct is not None:
try:
pct = float(stop_offset_pct)
if pct > 0:
return "offset", pct
except (TypeError, ValueError):
pass
if new_stop_loss is not None:
try:
sl = float(new_stop_loss)
if sl > 0:
ref = float(entry_ref or 0)
if ref > 0 and sl <= min(30.0, ref * 0.25):
return "offset", sl
return "absolute", sl
except (TypeError, ValueError):
pass
return "offset", ROLL_STOP_OFFSET_PCT_DEFAULT
def unified_stop_from_avg(direction: str, avg: float, offset_pct: float) -> float:
"""合并均价 ± offset% 作为新统一止损(非保本)。"""
avg_f = float(avg)
pct = float(offset_pct) / 100.0
if avg_f <= 0 or pct <= 0:
return 0.0
direction = (direction or "long").strip().lower()
if direction == "short":
return avg_f * (1.0 + pct)
return avg_f * (1.0 - pct)
def avg_entry_after_add(
qty_existing: float,
entry_existing: float,
@@ -102,44 +73,8 @@ def avg_entry_after_add(
return (q1 * e1 + q2 * e2) / total
def solve_add_amount_for_avg_stop_offset(
direction: str,
qty_existing: float,
entry_existing: float,
add_price: float,
offset_pct: float,
risk_budget_usdt: float,
) -> Tuple[Optional[float], Optional[str]]:
"""
合并后止损 = 合并均价 ± offset%且触及止损时总亏损 risk_budget
loss = offset% × (Q1·E1 + Q2·E2) => Q2 = (B/p Q1·E1) / E2
"""
try:
q1 = float(qty_existing)
e1 = float(entry_existing)
e2 = float(add_price)
b = float(risk_budget_usdt)
p = float(offset_pct) / 100.0
except (TypeError, ValueError):
return None, "参数格式错误"
if q1 <= 0 or e1 <= 0 or e2 <= 0 or b <= 0:
return None, "持仓或风险预算无效"
if p <= 0 or p >= 1:
return None, "止损偏移%须大于 0 且小于 100"
direction = (direction or "long").strip().lower()
need_notional = b / p
q2 = (need_notional - q1 * e1) / e2
if q2 <= 0:
return None, "按当前偏移%与总风险%,无需加仓或无法再加(已满足风险上限)"
new_avg = avg_entry_after_add(q1, e1, q2, e2)
sl = unified_stop_from_avg(direction, new_avg, offset_pct)
if direction == "short":
if sl <= e2:
return None, "做空:合并后止损须高于加仓价(请减小偏移%或风险%"
else:
if sl >= e2:
return None, "做多:合并后止损须低于加仓价(请减小偏移%或风险%"
return q2, None
def calc_risk_budget_usdt(capital_base_usdt: float, risk_percent: float) -> float:
return float(capital_base_usdt) * (float(risk_percent) / 100.0)
def solve_add_amount_for_total_risk(
@@ -149,11 +84,12 @@ def solve_add_amount_for_total_risk(
add_price: float,
new_stop: float,
risk_budget_usdt: float,
contract_size: float = 1.0,
) -> Tuple[Optional[float], Optional[str]]:
"""
已知合并后若触及 new_stop 总亏损=risk_budget反推本次加仓张数 Q2
long: (avg - SL) * (Q1+Q2) = B => Q2 = (B - Q1*(E1-SL)) / (E2-SL)
short: (SL - avg) * (Q1+Q2) = B => Q2 = (B - Q1*(SL-E1)) / (SL-E2)
合并持仓打到 new_stop 总亏损 risk_budget方案 C
long: (avg - SL) * (Q1+Q2) * cs = B => Q2 = (B/cs - Q1*(E1-SL)) / (E2-SL)
short: (SL - avg) * (Q1+Q2) * cs = B => Q2 = (B/cs - Q1*(SL-E1)) / (SL-E2)
"""
try:
q1 = float(qty_existing)
@@ -161,27 +97,196 @@ def solve_add_amount_for_total_risk(
e2 = float(add_price)
sl = float(new_stop)
b = float(risk_budget_usdt)
cs = float(contract_size) if contract_size else 1.0
except (TypeError, ValueError):
return None, "参数格式错误"
if q1 <= 0 or e1 <= 0 or e2 <= 0 or b <= 0:
if q1 <= 0 or e1 <= 0 or e2 <= 0 or b <= 0 or cs <= 0:
return None, "持仓或风险预算无效"
direction = (direction or "long").strip().lower()
if direction == "short":
denom = sl - e2
numer = b - q1 * (sl - e1)
numer = b / cs - q1 * (sl - e1)
if denom <= 0:
return None, "做空:新止损须高于限价加仓价"
return None, "做空:新止损须高于加仓价"
else:
denom = e2 - sl
numer = b - q1 * (e1 - sl)
numer = b / cs - q1 * (e1 - sl)
if denom <= 0:
return None, "做多:新止损须低于限价/市价加仓价"
return None, "做多:新止损须低于加仓价"
q2 = numer / denom
if q2 <= 0:
return None, "按当前新止损与风险%,无需加仓或无法再加(已满足风险上限)"
return None, "按当前新止损与风险预算,无需加仓或无法再加(已满足风险上限)"
return q2, None
def loss_at_stop_usdt(
direction: str,
avg: float,
qty: float,
stop: float,
contract_size: float = 1.0,
) -> float:
cs = float(contract_size or 1.0)
direction = (direction or "long").strip().lower()
if direction == "short":
return (float(stop) - float(avg)) * float(qty) * cs
return (float(avg) - float(stop)) * float(qty) * cs
def reward_at_tp_usdt(
direction: str,
avg: float,
take_profit: float,
qty: float,
contract_size: float = 1.0,
) -> float:
cs = float(contract_size or 1.0)
direction = (direction or "long").strip().lower()
if direction == "short":
return (float(avg) - float(take_profit)) * float(qty) * cs
return (float(take_profit) - float(avg)) * float(qty) * cs
def roll_fib_trigger_crossed(
direction: str,
prev_mark: Optional[float],
mark: float,
limit_price: float,
) -> bool:
"""斐波:多=向下穿越限价;空=向上穿越限价。"""
try:
m = float(mark)
lv = float(limit_price)
pm = float(prev_mark) if prev_mark is not None else None
except (TypeError, ValueError):
return False
direction = (direction or "long").strip().lower()
if direction == "long":
if pm is None:
return m <= lv
return pm > lv and m <= lv
if pm is None:
return m >= lv
return pm < lv and m >= lv
def roll_breakout_trigger_crossed(
direction: str,
prev_mark: Optional[float],
mark: float,
breakthrough_price: float,
) -> bool:
"""突破:多=向上穿越突破价;空=向下穿越突破价。"""
try:
m = float(mark)
bp = float(breakthrough_price)
pm = float(prev_mark) if prev_mark is not None else None
except (TypeError, ValueError):
return False
direction = (direction or "long").strip().lower()
if direction == "long":
if pm is None:
return m > bp
return pm <= bp and m > bp
if pm is None:
return m < bp
return pm >= bp and m < bp
def roll_fib_invalidate(direction: str, mark: float, upper: float, lower: float) -> bool:
"""斐波 pending 失效:止盈侧突破(多 mark>=H;空 mark<=L)。"""
return fib_invalidate_by_mark(direction, mark, upper, lower)
def roll_breakout_invalidate(direction: str, mark: float, stop_loss: float) -> bool:
"""突破 pending 失效:未到突破价先触达止损侧(多 mark<=S;空 mark>=S)。"""
try:
m = float(mark)
sl = float(stop_loss)
except (TypeError, ValueError):
return False
direction = (direction or "long").strip().lower()
if direction == "long":
return m <= sl
return m >= sl
def validate_roll_geometry(
direction: str,
add_mode: str,
*,
new_stop_loss: float,
add_price: Optional[float] = None,
fib_upper: Optional[float] = None,
fib_lower: Optional[float] = None,
breakthrough_price: Optional[float] = None,
entry_existing: float = 0.0,
initial_take_profit: float = 0.0,
mark_price: Optional[float] = None,
) -> Optional[str]:
direction = (direction or "long").strip().lower()
mode = (add_mode or MARKET_MODE).strip().lower()
try:
sl = float(new_stop_loss)
tp = float(initial_take_profit)
e1 = float(entry_existing or 0)
except (TypeError, ValueError):
return "止损/止盈格式错误"
if sl <= 0 or tp <= 0:
return "止损与首仓止盈须大于0"
if direction == "long":
if e1 > 0 and tp <= e1:
return "做多:首仓止盈须高于当前持仓均价"
else:
if e1 > 0 and tp >= e1:
return "做空:首仓止盈须低于当前持仓均价"
if mode == MARKET_MODE:
if add_price is None or float(add_price) <= 0:
return "市价加仓需要有效参考价"
entry_add = float(add_price)
elif mode in FIB_MODES:
if fib_upper is None or fib_lower is None:
return "斐波须填写上沿 H 与下沿 L"
entry_add, err = fib_limit_entry(direction, float(fib_upper), float(fib_lower), mode)
if err:
return err
if entry_add is None or entry_add <= 0:
return "无法计算斐波限价"
elif mode == BREAKOUT_MODE:
if breakthrough_price is None:
return "突破加仓须填写突破价"
try:
bp = float(breakthrough_price)
except (TypeError, ValueError):
return "突破价格式错误"
if bp <= 0:
return "突破价须大于0"
entry_add = bp
if direction == "long":
if sl >= bp:
return "做多:止损须低于突破价"
if mark_price is not None and float(mark_price) <= bp:
return "做多:当前价须高于突破价(等待向上突破)"
else:
if sl <= bp:
return "做空:止损须高于突破价"
if mark_price is not None and float(mark_price) >= bp:
return "做空:当前价须低于突破价(等待向下突破)"
else:
return "加仓方式无效"
if mode != BREAKOUT_MODE:
entry_add = float(entry_add) # type: ignore[arg-type]
if direction == "long":
if sl >= entry_add:
return "做多:新止损须低于加仓价"
else:
if sl <= entry_add:
return "做空:新止损须高于加仓价"
return None
def preview_roll(
*,
direction: str,
@@ -191,92 +296,78 @@ def preview_roll(
initial_take_profit: float,
add_mode: str,
new_stop_loss: Optional[float] = None,
stop_offset_pct: Optional[float] = None,
risk_percent: float,
capital_base_usdt: float,
add_price: Optional[float] = None,
fib_upper: Optional[float] = None,
fib_lower: Optional[float] = None,
breakthrough_price: Optional[float] = None,
legs_done: int = 0,
contract_size: float = 1.0,
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
direction = (direction or "long").strip().lower()
if legs_done >= max_roll_legs(direction):
return None, f"{'做多' if direction == 'long' else '做空'}滚仓已达 {max_roll_legs(direction)} 次上限"
mode = (add_mode or "market").strip().lower()
if mode == "market":
if add_price is None or add_price <= 0:
return None, "市价加仓需要有效参考价"
entry_add = float(add_price)
mode_label = "市价"
elif mode in FIB_MODES:
if fib_upper is None or fib_lower is None:
return None, "斐波限价须填写上沿与下沿"
entry_add, err = fib_limit_entry(direction, float(fib_upper), float(fib_lower), mode)
if err:
return None, err
mode_label = "斐波0.618" if "618" in mode else "斐波0.786"
else:
return None, "加仓方式无效"
mode = (add_mode or MARKET_MODE).strip().lower()
if new_stop_loss is None:
return None, "请填写新止损价"
try:
tp = float(initial_take_profit)
sl = float(new_stop_loss)
except (TypeError, ValueError):
return None, "格式错误"
if tp <= 0:
return None, "首仓止盈须大于0"
stop_mode, stop_val = resolve_roll_stop_spec(
new_stop_loss=new_stop_loss,
stop_offset_pct=stop_offset_pct,
entry_ref=entry_existing,
)
if direction == "long":
if tp <= entry_existing:
return None, "做多:首仓止盈须高于当前持仓均价参考"
else:
if tp >= entry_existing:
return None, "做空:首仓止盈须低于当前持仓均价参考"
risk_budget = float(capital_base_usdt) * (float(risk_percent) / 100.0)
offset_pct: Optional[float] = None
if stop_mode == "offset":
offset_pct = float(stop_val)
q2_raw, err = solve_add_amount_for_avg_stop_offset(
direction, qty_existing, entry_existing, entry_add, offset_pct, risk_budget
)
else:
sl = float(stop_val)
return None, "损价格式错误"
if sl <= 0:
return None, "止损须大于0"
if direction == "long":
if sl >= entry_add:
return None, "做多:新止损须低于加仓价"
geom_err = validate_roll_geometry(
direction,
mode,
new_stop_loss=sl,
add_price=add_price,
fib_upper=fib_upper,
fib_lower=fib_lower,
breakthrough_price=breakthrough_price,
entry_existing=entry_existing,
initial_take_profit=initial_take_profit,
mark_price=add_price if mode == BREAKOUT_MODE else add_price,
)
if geom_err:
return None, geom_err
if mode == MARKET_MODE:
entry_add = float(add_price) # validated
elif mode in FIB_MODES:
entry_add, _ = fib_limit_entry(direction, float(fib_upper), float(fib_lower), mode)
entry_add = float(entry_add or 0)
else:
if sl <= entry_add:
return None, "做空:新止损须高于加仓价"
entry_add = float(breakthrough_price or 0)
risk_budget = calc_risk_budget_usdt(capital_base_usdt, risk_percent)
q2_raw, err = solve_add_amount_for_total_risk(
direction, qty_existing, entry_existing, entry_add, sl, risk_budget
direction,
qty_existing,
entry_existing,
entry_add,
sl,
risk_budget,
contract_size,
)
if err:
return None, err
q2 = float(q2_raw)
new_qty = qty_existing + q2
new_avg = avg_entry_after_add(qty_existing, entry_existing, q2, entry_add)
if stop_mode == "offset":
sl = unified_stop_from_avg(direction, new_avg, offset_pct)
if direction == "long":
loss_at_sl = (new_avg - sl) * new_qty
reward_at_tp = (tp - new_avg) * new_qty
else:
loss_at_sl = (sl - new_avg) * new_qty
reward_at_tp = (new_avg - tp) * new_qty
cs = float(contract_size or 1.0)
loss_sl = loss_at_stop_usdt(direction, new_avg, new_qty, sl, cs)
reward_tp = reward_at_tp_usdt(direction, new_avg, initial_take_profit, new_qty, cs)
return {
"symbol": symbol,
"direction": direction,
"add_mode": mode,
"add_mode_label": mode_label,
"add_mode_label": mode_label(mode),
"add_price": round(entry_add, 10),
"new_stop_loss": round(sl, 10),
"stop_offset_pct": offset_pct,
"stop_mode": stop_mode,
"initial_take_profit": tp,
"breakthrough_price": float(breakthrough_price) if breakthrough_price not in (None, "") else None,
"initial_take_profit": float(initial_take_profit),
"risk_percent": float(risk_percent),
"risk_budget_usdt": round(risk_budget, 4),
"add_amount_raw": q2,
@@ -284,27 +375,11 @@ def preview_roll(
"entry_existing": float(entry_existing),
"qty_after": new_qty,
"avg_entry_after": round(new_avg, 10),
"loss_at_sl_usdt": round(loss_at_sl, 4),
"reward_at_tp_usdt": round(reward_at_tp, 4),
"loss_at_sl_usdt": round(loss_sl, 4),
"reward_at_tp_usdt": round(reward_tp, 4),
"legs_done": int(legs_done),
"leg_index_next": int(legs_done) + 1,
"fib_upper": fib_upper,
"fib_lower": fib_lower,
"contract_size": cs,
}, None
def roll_stop_after_fill(
direction: str,
qty_before: float,
entry_before: float,
add_qty: float,
fill_price: float,
*,
stop_offset_pct: Optional[float] = None,
absolute_stop: Optional[float] = None,
) -> float:
"""成交后按合并均价重算统一止损(偏移%模式)或沿用绝对止损。"""
if stop_offset_pct is not None and float(stop_offset_pct) > 0:
avg = avg_entry_after_add(qty_before, entry_before, add_qty, fill_price)
return unified_stop_from_avg(direction, avg, float(stop_offset_pct))
return float(absolute_stop or 0)
+305 -113
View File
@@ -1,17 +1,29 @@
"""滚仓挂单监控:斐波限价止盈侧突破撤单、成交同步、活跃组结案(各所共用)。"""
"""滚仓程序监控:斐波/突破触价市价成交、失效、外部平仓同步(各所共用)。"""
from __future__ import annotations
from typing import Any, Optional
from fib_key_monitor_lib import fib_invalidate_by_mark
from strategy_roll_lib import unified_stop_from_avg
from strategy_roll_lib import (
BREAKOUT_MODE,
FIB_MODES,
MARKET_MODE,
mode_label,
roll_breakout_invalidate,
roll_breakout_trigger_crossed,
roll_fib_invalidate,
roll_fib_trigger_crossed,
calc_risk_budget_usdt,
max_roll_legs,
preview_roll,
solve_add_amount_for_total_risk,
)
from strategy_db import init_strategy_tables
ROLL_LEG_STATUS_LABELS = {
"pending": "挂单",
"pending": "监控",
"filled": "已成交",
"cancelled": "撤销",
"invalidated": "止盈侧突破失效",
"cancelled": "删除",
"invalidated": "失效",
}
@@ -40,6 +52,97 @@ def check_roll_monitors(cfg: dict[str, Any]) -> None:
pass
def sync_roll_after_external_close(
cfg: dict, conn, symbol: str, direction: str, *, reason: str = "持仓已平"
) -> dict[str, Any]:
"""中控/实例手动平仓后:取消 pending 腿并关闭 active 滚仓组(保留 filled 历史)。"""
norm = cfg.get("normalize_symbol_input")
sym = norm(symbol) if callable(norm) else (symbol or "").strip()
if not sym:
return {"ok": False, "msg": "symbol 无效", "closed_groups": 0, "cancelled_legs": 0}
direction = (direction or "long").strip().lower()
init_strategy_tables(conn)
rows = conn.execute(
"""SELECT g.* FROM roll_groups g
WHERE g.status='active' AND g.symbol=? AND g.direction=?""",
(sym, direction),
).fetchall()
closed = cancelled = 0
for row in rows:
g = _row_dict(row)
cancelled += _cancel_pending_legs_for_group(conn, cfg, g, status="cancelled")
cur = conn.execute(
"UPDATE roll_groups SET status='closed', updated_at=? WHERE id=? AND status='active'",
(_now(cfg), int(g["id"])),
)
if getattr(cur, "rowcount", 0):
closed += 1
try:
from strategy_wechat_notify import notify_roll_group_ended
notify_roll_group_ended(
cfg,
group_id=int(g["id"]),
symbol=sym,
direction=direction,
reason=reason,
leg_count=int(g.get("leg_count") or 0),
)
except Exception:
pass
try:
from strategy_snapshot_lib import save_roll_group_snapshot
save_roll_group_snapshot(cfg, conn, g, result_label="结束")
except Exception:
pass
return {
"ok": True,
"symbol": sym,
"direction": direction,
"closed_groups": closed,
"cancelled_legs": cancelled,
}
def cancel_roll_pending_leg(cfg: dict, conn, leg_id: int) -> tuple[bool, str]:
"""用户删除 pending 滚仓腿(不可修改,仅删除)。"""
init_strategy_tables(conn)
row = conn.execute(
"SELECT l.*, g.symbol, g.direction, g.status AS group_status FROM roll_legs l "
"INNER JOIN roll_groups g ON g.id = l.roll_group_id WHERE l.id=?",
(int(leg_id),),
).fetchone()
if not row:
return False, "滚仓腿不存在"
leg = _row_dict(row)
if (leg.get("status") or "").strip().lower() != "pending":
return False, "仅监控中的腿可删除"
_cancel_roll_leg_order(cfg, {"symbol": leg.get("symbol"), "exchange_symbol": leg.get("exchange_symbol")}, leg)
conn.execute(
"UPDATE roll_legs SET status='cancelled' WHERE id=? AND status='pending'",
(int(leg_id),),
)
conn.commit()
return True, "已删除滚仓监控"
def count_filled_roll_legs(conn, roll_group_id: int) -> int:
row = conn.execute(
"SELECT COUNT(*) FROM roll_legs WHERE roll_group_id=? AND status='filled'",
(int(roll_group_id),),
).fetchone()
return int(row[0] if row else 0)
def count_pending_roll_legs(conn, roll_group_id: int) -> int:
row = conn.execute(
"SELECT COUNT(*) FROM roll_legs WHERE roll_group_id=? AND status='pending'",
(int(roll_group_id),),
).fetchone()
return int(row[0] if row else 0)
def _row_dict(row) -> dict:
if row is None:
return {}
@@ -54,15 +157,9 @@ def _now(cfg: dict) -> str:
return fn() if callable(fn) else ""
def _close_roll_group(
conn,
cfg: dict,
group: dict,
*,
cancel_pending: bool = True,
) -> None:
def _cancel_pending_legs_for_group(conn, cfg: dict, group: dict, *, status: str = "cancelled") -> int:
gid = int(group["id"])
if cancel_pending:
n = 0
for leg in conn.execute(
"SELECT * FROM roll_legs WHERE roll_group_id=? AND status='pending'",
(gid,),
@@ -70,9 +167,16 @@ def _close_roll_group(
ld = _row_dict(leg)
_cancel_roll_leg_order(cfg, group, ld)
conn.execute(
"UPDATE roll_legs SET status='cancelled' WHERE id=? AND status='pending'",
(ld["id"],),
"UPDATE roll_legs SET status=? WHERE id=? AND status='pending'",
(status, ld["id"]),
)
n += 1
return n
def _close_roll_group(conn, cfg: dict, group: dict, *, reason: str = "下单监控已结案或交易所无同向持仓") -> None:
gid = int(group["id"])
_cancel_pending_legs_for_group(conn, cfg, group, status="cancelled")
cur = conn.execute(
"UPDATE roll_groups SET status='closed', updated_at=? WHERE id=? AND status='active'",
(_now(cfg), gid),
@@ -81,7 +185,6 @@ def _close_roll_group(
try:
from strategy_wechat_notify import notify_roll_group_ended
reason = "下单监控已结案或交易所无同向持仓"
notify_roll_group_ended(
cfg,
group_id=gid,
@@ -116,7 +219,7 @@ def _reconcile_roll_groups(conn, cfg: dict) -> None:
pos = cfg["get_position"](ex_sym, direction)
qty = float(pos.get("contracts") or 0)
if not mon_ok or qty <= 0:
_close_roll_group(conn, cfg, g, cancel_pending=True)
_close_roll_group(conn, cfg, g)
def _cancel_roll_leg_order(cfg: dict, group: dict, leg: dict) -> None:
@@ -133,10 +236,35 @@ def _cancel_roll_leg_order(cfg: dict, group: dict, leg: dict) -> None:
pass
def _contract_size(cfg: dict, ex_sym: str) -> float:
get_cs = cfg.get("get_contract_size")
if callable(get_cs):
try:
return float(get_cs(ex_sym) or 1.0)
except Exception:
pass
return 1.0
def _resolve_add_mode(leg: dict) -> str:
raw = (leg.get("add_mode") or "").strip().lower()
if raw in (MARKET_MODE, "market", "市价", "市价加仓"):
return MARKET_MODE
if "786" in raw or raw == "fib_786":
return "fib_786"
if "618" in raw or raw == "fib_618":
return "fib_618"
if raw in (BREAKOUT_MODE, "突破", "突破加仓"):
return BREAKOUT_MODE
if raw.startswith("fib"):
return raw.replace(".", "_").replace("0.", "0")
return raw or MARKET_MODE
def _check_pending_roll_legs(conn, cfg: dict) -> None:
rows = conn.execute(
"""SELECT l.*, g.symbol, g.exchange_symbol, g.direction, g.initial_take_profit,
g.order_monitor_id
g.order_monitor_id, g.risk_percent, g.leg_count
FROM roll_legs l
INNER JOIN roll_groups g ON g.id = l.roll_group_id AND g.status='active'
WHERE l.status='pending'"""
@@ -150,6 +278,8 @@ def _check_pending_roll_legs(conn, cfg: dict) -> None:
"direction": leg["direction"],
"initial_take_profit": leg["initial_take_profit"],
"order_monitor_id": leg["order_monitor_id"],
"risk_percent": leg.get("risk_percent"),
"leg_count": leg.get("leg_count"),
}
_process_pending_roll_leg(conn, cfg, group, leg)
@@ -158,56 +288,51 @@ def _process_pending_roll_leg(conn, cfg: dict, group: dict, leg: dict) -> None:
symbol = group.get("symbol") or ""
direction = (group.get("direction") or "long").strip().lower()
ex_sym = group.get("exchange_symbol") or cfg["normalize_exchange_symbol"](symbol)
oid = (leg.get("exchange_order_id") or "").strip()
mark_fn = cfg.get("get_mark_price") or cfg.get("get_price")
mark = mark_fn(symbol) if callable(mark_fn) else None
if mark is None:
return
mark_f = float(mark)
prev_mark = leg.get("last_mark_price")
try:
prev_f = float(prev_mark) if prev_mark not in (None, "") else None
except (TypeError, ValueError):
prev_f = None
order_status_fn = cfg.get("limit_order_status")
order_st = order_status_fn(ex_sym, oid) if callable(order_status_fn) and oid else "missing"
mode = _resolve_add_mode(leg)
sl = float(leg.get("new_stop_loss") or 0)
fib_u, fib_l = leg.get("fib_upper"), leg.get("fib_lower")
has_fib = fib_u is not None and fib_l is not None
bp = leg.get("breakthrough_price")
if order_st == "filled":
_finalize_roll_leg_fill(conn, cfg, group, leg, ex_sym, direction, float(mark))
if mode in FIB_MODES and fib_u is not None and fib_l is not None:
if roll_fib_invalidate(direction, mark_f, float(fib_u), float(fib_l)):
_invalidate_roll_leg(conn, cfg, group, leg, mark_f, reason="止盈侧突破")
return
elif mode == BREAKOUT_MODE and sl > 0:
if roll_breakout_invalidate(direction, mark_f, sl):
_invalidate_roll_leg(conn, cfg, group, leg, mark_f, reason="止损侧突破")
return
if has_fib and fib_invalidate_by_mark(direction, mark, fib_u, fib_l):
if order_st == "open":
_cancel_roll_leg_order(cfg, group, leg)
_invalidate_roll_leg(conn, cfg, group, leg, float(mark))
return
triggered = False
if mode in FIB_MODES:
lp = leg.get("limit_price")
if lp is not None and roll_fib_trigger_crossed(direction, prev_f, mark_f, float(lp)):
triggered = True
elif mode == BREAKOUT_MODE and bp is not None:
if roll_breakout_trigger_crossed(direction, prev_f, mark_f, float(bp)):
triggered = True
if order_st in ("canceled", "missing", "unknown") and has_fib:
if fib_invalidate_by_mark(direction, mark, fib_u, fib_l):
_invalidate_roll_leg(conn, cfg, group, leg, float(mark))
def _invalidate_roll_leg(
conn, cfg: dict, group: dict, leg: dict, mark: float
) -> None:
leg_id = int(leg["id"])
gid = int(group["id"])
cur = conn.execute(
"SELECT status FROM roll_legs WHERE id=?", (leg_id,)
).fetchone()
if not cur or (cur[0] or "").strip().lower() == "invalidated":
return
conn.execute(
"UPDATE roll_legs SET status='invalidated' WHERE id=? AND status='pending'",
(leg_id,),
"UPDATE roll_legs SET last_mark_price=? WHERE id=? AND status='pending'",
(mark_f, int(leg["id"])),
)
conn.execute(
"""UPDATE roll_groups SET leg_count = CASE WHEN leg_count > 0 THEN leg_count - 1 ELSE 0 END,
updated_at=? WHERE id=?""",
(_now(cfg), gid),
)
_send_roll_invalidate_wechat(cfg, group, leg, mark)
if triggered:
_execute_pending_roll_leg(conn, cfg, group, leg, ex_sym, direction, mark_f)
return
def _finalize_roll_leg_fill(
def _execute_pending_roll_leg(
conn,
cfg: dict,
group: dict,
@@ -217,94 +342,161 @@ def _finalize_roll_leg_fill(
mark: float,
) -> None:
leg_id = int(leg["id"])
gid = int(group["id"])
new_sl = float(leg.get("new_stop_loss") or 0)
stop_offset_pct = leg.get("stop_offset_pct")
tp0 = float(group.get("initial_take_profit") or 0)
fill_px = float(leg.get("limit_price") or mark)
add_qty = float(leg.get("amount") or 0)
if stop_offset_pct not in (None, ""):
try:
offset_pct = float(stop_offset_pct)
except (TypeError, ValueError):
offset_pct = 0.0
if offset_pct > 0:
pos = cfg["get_position"](ex_sym, direction) or {}
avg = float(pos.get("entry_price") or 0)
if avg <= 0 and add_qty > 0:
avg = fill_px
if avg > 0:
new_sl = unified_stop_from_avg(direction, avg, offset_pct)
px_fn = cfg.get("price_to_precision")
if callable(px_fn):
try:
new_sl = float(px_fn(ex_sym, new_sl) or new_sl)
except Exception:
pass
conn.execute(
"UPDATE roll_legs SET status='filled', fill_price=?, new_stop_loss=? WHERE id=? AND status='pending'",
(fill_px, new_sl, leg_id),
)
if new_sl > 0:
conn.execute(
"UPDATE roll_groups SET current_stop_loss=?, updated_at=? WHERE id=?",
(new_sl, _now(cfg), gid),
)
gid = int(group["roll_group_id"]) if "roll_group_id" in leg else int(group["id"])
mon_id = group.get("order_monitor_id")
if mon_id and new_sl > 0:
conn.execute(
"UPDATE order_monitors SET stop_loss=? WHERE id=? AND status='active'",
(new_sl, mon_id),
)
replace = cfg.get("replace_tpsl")
if callable(replace) and new_sl > 0 and tp0 > 0:
mon = None
if mon_id:
row = conn.execute(
"SELECT * FROM order_monitors WHERE id=?", (mon_id,)
).fetchone()
row = conn.execute("SELECT * FROM order_monitors WHERE id=?", (mon_id,)).fetchone()
mon = _row_dict(row) if row else None
if not mon or (mon.get("status") or "").strip().lower() != "active":
_invalidate_roll_leg(conn, cfg, group, leg, mark, reason="监控单已失效")
return
pos = cfg["get_position"](ex_sym, direction) or {}
qty = float(pos.get("contracts") or 0)
entry = float(pos.get("entry_price") or mon.get("trigger_price") or 0)
if qty <= 0 or entry <= 0:
_invalidate_roll_leg(conn, cfg, group, leg, mark, reason="无持仓")
return
filled = count_filled_roll_legs(conn, gid)
if filled >= max_roll_legs(direction):
_invalidate_roll_leg(conn, cfg, group, leg, mark, reason="滚仓次数已满")
return
try:
replace(ex_sym, direction, new_sl, tp0, mon)
except Exception:
pass
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
)
except Exception as e:
fe = cfg.get("friendly_error")
msg = fe(e) if callable(fe) else str(e)
_notify_roll_fail(cfg, group, leg, mark, msg)
return
oid = str(order.get("id") or "") if isinstance(order, dict) else ""
cfg["replace_tpsl"](ex_sym, direction, sl, tp0, mon)
conn.execute(
"""UPDATE roll_legs SET status='filled', fill_price=?, amount=?, exchange_order_id=?,
new_stop_loss=? WHERE id=? AND status='pending'""",
(fill, float(amount), oid, sl, leg_id),
)
conn.execute(
"UPDATE roll_groups SET leg_count=?, current_stop_loss=?, updated_at=? WHERE id=?",
(filled + 1, sl, _now(cfg), gid),
)
conn.execute(
"UPDATE order_monitors SET stop_loss=? WHERE id=? AND status='active'",
(sl, mon["id"]),
)
notify = cfg.get("send_wechat")
if callable(notify):
sym = group.get("symbol") or ""
mode = leg.get("add_mode") or "限价"
mode_lbl = leg.get("add_mode") or mode_label(mode)
fmt = cfg.get("format_price")
px_txt = fmt(sym, fill_px) if callable(fmt) else str(fill_px)
sl_txt = fmt(sym, new_sl) if callable(fmt) else str(new_sl)
px_txt = fmt(sym, fill) if callable(fmt) else str(fill)
sl_txt = fmt(sym, sl) if callable(fmt) else str(sl)
acct = _wechat_account(cfg)
dir_txt = _wechat_dir(cfg, direction)
notify(
f"# ✅ {sym} 滚仓限价已成交\n"
f"# ✅ {sym} 滚仓触价成交\n"
f"**账户:{acct}**\n"
f"- 方式:{mode}{dir_txt}\n"
f"- 成交价:{px_txt}新止损{sl_txt}\n"
f"- 交易所止损已尝试同步(止盈仍为首仓)\n"
f"- 方式:{mode_lbl}{dir_txt}\n"
f"- 成交价:{px_txt}张数{amount}\n"
f"- 新止损:{sl_txt}(止盈仍为首仓)\n"
)
def _invalidate_roll_leg(
conn,
cfg: dict,
group: dict,
leg: dict,
mark: float,
*,
reason: str = "",
) -> None:
leg_id = int(leg["id"])
cur = conn.execute("SELECT status FROM roll_legs WHERE id=?", (leg_id,)).fetchone()
if not cur or (cur[0] or "").strip().lower() in ("invalidated", "filled", "cancelled"):
return
_cancel_roll_leg_order(cfg, group, leg)
conn.execute(
"UPDATE roll_legs SET status='invalidated' WHERE id=? AND status='pending'",
(leg_id,),
)
_send_roll_invalidate_wechat(cfg, group, leg, mark, reason=reason)
def _notify_roll_fail(cfg: dict, group: dict, leg: dict, mark: float, reason: str) -> None:
notify = cfg.get("send_wechat")
if not callable(notify):
return
sym = group.get("symbol") or ""
mode = leg.get("add_mode") or "滚仓"
acct = _wechat_account(cfg)
notify(
f"# ❌ {sym} 滚仓触价成交失败\n"
f"**账户:{acct}**\n"
f"- 方式:{mode}\n"
f"- 原因:{reason}\n"
)
def _send_roll_invalidate_wechat(
cfg: dict, group: dict, leg: dict, mark: float
cfg: dict, group: dict, leg: dict, mark: float, *, reason: str = ""
) -> None:
notify = cfg.get("send_wechat")
if not callable(notify):
return
sym = group.get("symbol") or ""
direction = (group.get("direction") or "long").strip().lower()
mode = leg.get("add_mode") or "斐波限价"
mode = leg.get("add_mode") or "滚仓监控"
fmt = cfg.get("format_price")
mark_txt = fmt(sym, mark) if callable(fmt) else str(mark)
acct = _wechat_account(cfg)
dir_txt = _wechat_dir(cfg, direction)
detail = reason or "条件不满足"
notify(
f"# ⚠️ {sym} 滚仓斐波挂单失效\n"
f"# ⚠️ {sym} 滚仓监控失效\n"
f"**账户:{acct}**\n"
f"- 方式:{mode}{dir_txt}\n"
f"- 标记价 {mark_txt} 已触达止盈侧(未成交),已撤限价加仓单\n"
f"- 本条滚仓腿已结案,可继续下一档或重新挂单\n"
f"- 标记价 {mark_txt}{detail}\n"
f"- 本条监控已结案,可重新提交\n"
)
+3 -108
View File
@@ -5,119 +5,14 @@
<script src="/static/instance_theme.js?v=4"></script>
<meta name="viewport" content="width=device-width, initial-scale=1">
<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">
<meta name="theme-color" content="#0b0d14">
</head>
<body>
<div class="container">
<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>
<div class="container" style="max-width:1100px;margin:0 auto;padding:16px">
{% with messages = get_flashed_messages() %}{% if messages %}<div class="flash">{{ messages[0] }}</div>{% endif %}{% endwith %}
<div class="card">
<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>
{% include 'strategy_roll_panel.html' %}
<p class="rule-tip" style="margin-top:12px"><a href="/strategy/roll/docs" style="color:#8fc8ff">顺势加仓完整逻辑说明</a></p>
</div>
</body>
</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>
<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>
<div class="tip-collapse-body rule-tip">
<strong>仅人工加仓</strong>,程序不会自动触发。须先在「实盘下单」有同向持仓。<br>
做多最多滚仓 <strong>3</strong> 次;止盈<strong>锁定首仓</strong>不变;每次填写<strong>止损偏移%</strong>(相对合并均价,默认 1%),总风险%按「合并持仓打到新止损≈账户风险」反推张数<br>
斐波限价:上沿 H、下沿 L 仅用于算 0.618/0.786 加仓价(多:下沿=止损侧;空:上沿=止损侧)。<br>
<strong>仓位上限冻结时仍可顺势加仓</strong>(在已有同向监控持仓上操作,不占用新仓名额)<br>
<strong>仅人工提交</strong>须先在「实盘下单」有同向持仓。<strong>以损定仓</strong>模式可用。<br>
做多/做空各最多滚仓 <strong>3</strong>(仅计已成交腿);止盈<strong>锁定首仓</strong>不变。<br>
风险比例读取所选监控单,<strong>不可手改</strong>;打到新止损时合并持仓亏损 ≈ 1 个风险单位(当前基数 × 监控 risk%)。<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 %}
</div>
</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>
{% 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 %}
</select>
<select name="direction">
<option value="long">做多</option>
<option value="short">做空</option>
</select>
<select name="add_mode">
<input type="hidden" name="direction" id="roll-direction" value="long">
<select name="add_mode" id="roll-add-mode">
<option value="market">市价加仓</option>
<option value="fib_618">限价 斐波0.618</option>
<option value="fib_786">限价 斐波0.786</option>
<option value="fib_618">斐波 0.618</option>
<option value="fib_786">斐波 0.786</option>
<option value="breakout">突破加仓</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 roll_trend_active %}disabled style="opacity:.5"{% endif %} onclick="return confirm('确认按预览逻辑实盘加仓并更新止损?')">执行滚仓</button>
<span class="roll-field roll-field-fib">
<input name="fib_upper" id="roll-fib-upper" step="any" placeholder="沿 H">
<input name="fib_lower" id="roll-fib-lower" step="any" placeholder="下沿 L">
</span>
<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>
<details class="tip-collapse strategy-roll-preview-tip">
<summary class="tip-collapse-summary">滚仓预览接口说明</summary>
<div class="tip-collapse-body rule-tip">执行前可用开发者工具 POST <code>/strategy/roll/preview</code> 查看 JSON 预览。</div>
</details>
<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 id="roll-preview-text"></div>
<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>
<div class="table-wrap">
@@ -61,17 +77,23 @@
<h3 style="margin:14px 0 8px;font-size:.95rem;color:#b8c4ff">最近滚仓腿</h3>
<div class="table-wrap">
<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 %}
<tr>
<td>{{ leg.leg_index }}</td>
<td>{{ leg.roll_group_id }}</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>{% 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>
{% 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>
{% else %}
<tr><td colspan="8" style="color:#8892b0">暂无</td></tr>
@@ -79,3 +101,4 @@
</table>
</div>
</div>
<script src="/static/strategy_roll.js?v=1"></script>
+46 -35
View File
@@ -1,29 +1,24 @@
from strategy_roll_lib import (
preview_roll,
resolve_roll_stop_spec,
roll_stop_after_fill,
unified_stop_from_avg,
roll_breakout_invalidate,
roll_breakout_trigger_crossed,
roll_fib_invalidate,
roll_fib_trigger_crossed,
solve_add_amount_for_total_risk,
)
def test_resolve_roll_stop_spec_treats_small_value_as_offset_pct():
mode, val = resolve_roll_stop_spec(new_stop_loss=1.0, entry_ref=63.976)
assert mode == "offset"
assert val == 1.0
def test_solve_add_amount_long_one_risk():
q2, err = solve_add_amount_for_total_risk(
"long", 1.0, 3000.0, 3100.0, 2950.0, 200.0, 1.0
)
assert err is None
avg = (1 * 3000 + q2 * 3100) / (1 + q2)
loss = (avg - 2950) * (1 + q2)
assert abs(loss - 200.0) < 0.01
def test_resolve_roll_stop_spec_treats_price_as_absolute():
mode, val = resolve_roll_stop_spec(new_stop_loss=64.6, entry_ref=63.976)
assert mode == "absolute"
assert val == 64.6
def test_unified_stop_from_avg_short_one_percent():
sl = unified_stop_from_avg("short", 63.976, 1.0)
assert abs(sl - 63.976 * 1.01) < 1e-6
def test_preview_roll_offset_mode_not_breakeven():
def test_preview_roll_market_short():
preview, err = preview_roll(
direction="short",
symbol="HYPE/USDT",
@@ -31,29 +26,45 @@ def test_preview_roll_offset_mode_not_breakeven():
entry_existing=65.0,
initial_take_profit=60.0,
add_mode="market",
stop_offset_pct=1.0,
new_stop_loss=66.5,
risk_percent=2.0,
capital_base_usdt=1000.0,
add_price=64.0,
legs_done=1,
)
assert err is None
assert preview["stop_mode"] == "offset"
assert preview["stop_offset_pct"] == 1.0
avg = preview["avg_entry_after"]
assert preview["add_mode_label"] == "市价加仓"
sl = preview["new_stop_loss"]
assert sl > avg * 1.009
assert sl < avg * 1.011
avg = preview["avg_entry_after"]
qty = preview["qty_after"]
loss = (sl - avg) * qty
assert abs(loss - 20.0) < 0.01
def test_roll_stop_after_fill_recomputes_from_actual_fill():
sl = roll_stop_after_fill(
"short",
qty_before=3.0,
entry_before=65.0,
add_qty=5.0,
fill_price=63.5,
stop_offset_pct=1.0,
def test_fib_cross_long_down():
assert roll_fib_trigger_crossed("long", 101.0, 100.0, 100.5) is True
assert roll_fib_trigger_crossed("long", 100.6, 100.6, 100.5) is False
def test_breakout_cross_long_up():
assert roll_breakout_trigger_crossed("long", 99.0, 100.5, 100.0) is True
assert roll_breakout_invalidate("long", 98.0, 99.0) is True
assert roll_fib_invalidate("long", 110.0, 105.0, 95.0) is True
def test_preview_breakout_mode_label():
preview, err = preview_roll(
direction="long",
symbol="ETH/USDT",
qty_existing=1.0,
entry_existing=3000.0,
initial_take_profit=3500.0,
add_mode="breakout",
new_stop_loss=2980.0,
breakthrough_price=3100.0,
risk_percent=10.0,
capital_base_usdt=1000.0,
add_price=3150.0,
)
avg = (3 * 65.0 + 5 * 63.5) / 8.0
assert abs(sl - avg * 1.01) < 1e-6
assert err is None
assert preview["add_mode_label"] == "突破加仓"
+2
View File
@@ -57,6 +57,8 @@ strategy_records_register.py # /strategy/records 路由与列表数据
## 四、顺势加仓(滚仓,仅人工)
> **详细说明**(计仓公式、四种方式、程序监控、生命周期):仓库 [`顺势加仓滚仓说明.md`](./顺势加仓滚仓说明.md);各实例策略页 **[`/strategy/roll/docs`](/strategy/roll/docs)** 可在线阅读。
### 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 **不可改、可删**;手动平仓自动结束滚仓监控