顺势加仓 v2:程序监控滚仓、文档页与平仓同步
重写滚仓计仓与四种加仓方式(市价/斐波/突破),程序盯 mark 触价成交;风险读监控单;pending 可删不可改;手动平仓同步结束滚仓。新增 /strategy/roll/docs 说明页与顺势加仓滚仓说明.md。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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("该仓位在交易所已不存在,已按成交记录同步结束并记账")
|
||||||
|
|||||||
@@ -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("该仓位在交易所已不存在,已按成交记录同步结束并记账")
|
||||||
|
|||||||
@@ -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("该仓位在交易所已不存在,已按成交记录同步结束并记账")
|
||||||
|
|||||||
@@ -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("该仓位在交易所已不存在,已按成交记录同步结束并记账")
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
})();
|
||||||
@@ -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)
|
||||||
|
|||||||
+336
-162
@@ -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)
|
||||||
|
if pending > 0:
|
||||||
conn.close()
|
conn.close()
|
||||||
pos = cfg["get_position"](ex_sym, direction)
|
return None, "已有监控中的滚仓腿,请等待成交/失效或先删除后再提交"
|
||||||
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": "止损格式错误"}
|
|
||||||
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,79 +443,27 @@ 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)(
|
||||||
|
order, ex_sym, preview["add_price"]
|
||||||
|
)
|
||||||
|
or preview["add_price"]
|
||||||
|
)
|
||||||
oid = str(order.get("id") or "") if isinstance(order, dict) else ""
|
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)
|
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, fill_price, amount, new_stop_loss, exchange_order_id,
|
||||||
|
status, created_at
|
||||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
(
|
(
|
||||||
rg["id"],
|
rg["id"],
|
||||||
@@ -343,10 +472,10 @@ def _roll_execute(cfg: dict, data: dict) -> tuple[bool, str]:
|
|||||||
preview.get("fib_upper"),
|
preview.get("fib_upper"),
|
||||||
preview.get("fib_lower"),
|
preview.get("fib_lower"),
|
||||||
None,
|
None,
|
||||||
|
preview.get("breakthrough_price"),
|
||||||
fill,
|
fill,
|
||||||
amount,
|
amount,
|
||||||
new_sl,
|
new_sl,
|
||||||
stop_offset_pct,
|
|
||||||
oid,
|
oid,
|
||||||
"filled",
|
"filled",
|
||||||
cfg["app_now_str"](),
|
cfg["app_now_str"](),
|
||||||
@@ -361,24 +490,39 @@ def _roll_execute(cfg: dict, data: dict) -> tuple[bool, str]:
|
|||||||
(new_sl, mon["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"市价加仓第 {legs_done + 1} 腿已成交,止损已更新,止盈仍为首仓"
|
||||||
notify_roll_group_started,
|
# 程序监控:斐波 / 突破
|
||||||
|
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"](),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
conn.commit()
|
||||||
if roll_is_new:
|
_maybe_notify_roll_started(cfg, rg, mon, symbol, direction, tp0, new_sl, roll_is_new=roll_is_new)
|
||||||
notify_roll_group_started(
|
return True, f"已提交{preview['add_mode_label']}监控,触价后将市价加仓并更新止损"
|
||||||
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="手动平仓,滚仓监控已结束"
|
||||||
|
)
|
||||||
|
|
||||||
|
|||||||
+245
-170
@@ -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:
|
|
||||||
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)
|
|
||||||
if sl <= 0:
|
if sl <= 0:
|
||||||
return None, "止损须大于0"
|
return None, "止损须大于0"
|
||||||
if direction == "long":
|
|
||||||
if sl >= entry_add:
|
geom_err = validate_roll_geometry(
|
||||||
return None, "做多:新止损须低于加仓价"
|
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:
|
else:
|
||||||
if sl <= entry_add:
|
entry_add = float(breakthrough_price or 0)
|
||||||
return None, "做空:新止损须高于加仓价"
|
|
||||||
|
risk_budget = calc_risk_budget_usdt(capital_base_usdt, risk_percent)
|
||||||
q2_raw, err = solve_add_amount_for_total_risk(
|
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:
|
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)
|
|
||||||
|
|||||||
+305
-113
@@ -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,15 +157,9 @@ 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,),
|
||||||
@@ -70,9 +167,16 @@ def _close_roll_group(
|
|||||||
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)):
|
||||||
|
_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
|
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:
|
|
||||||
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
|
mon = None
|
||||||
if mon_id:
|
if mon_id:
|
||||||
row = conn.execute(
|
row = conn.execute("SELECT * FROM order_monitors WHERE id=?", (mon_id,)).fetchone()
|
||||||
"SELECT * FROM order_monitors WHERE id=?", (mon_id,)
|
|
||||||
).fetchone()
|
|
||||||
mon = _row_dict(row) if row else None
|
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:
|
try:
|
||||||
replace(ex_sym, direction, new_sl, tp0, mon)
|
risk_pct = float(mon.get("risk_percent") or group.get("risk_percent") or 2)
|
||||||
except Exception:
|
except (TypeError, ValueError):
|
||||||
pass
|
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")
|
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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
·
|
||||||
|
<a href="/strategy/roll">顺势加仓</a>
|
||||||
|
</div>
|
||||||
|
<article class="doc-body">
|
||||||
|
{{ doc_html|safe }}
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"] == "突破加仓"
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ strategy_records_register.py # /strategy/records 路由与列表数据
|
|||||||
|
|
||||||
## 四、顺势加仓(滚仓,仅人工)
|
## 四、顺势加仓(滚仓,仅人工)
|
||||||
|
|
||||||
|
> **详细说明**(计仓公式、四种方式、程序监控、生命周期):仓库 [`顺势加仓滚仓说明.md`](./顺势加仓滚仓说明.md);各实例策略页 **[`/strategy/roll/docs`](/strategy/roll/docs)** 可在线阅读。
|
||||||
|
|
||||||
### 4.1 原则
|
### 4.1 原则
|
||||||
|
|
||||||
- **禁止自动加仓**;仅页面按钮「执行滚仓」或挂限价单(无价格穿越自动下单)。
|
- **禁止自动加仓**;仅页面按钮「执行滚仓」或挂限价单(无价格穿越自动下单)。
|
||||||
|
|||||||
+174
@@ -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 closed,pending 清除
|
||||||
|
```
|
||||||
|
|
||||||
|
| 规则 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 提交后不可改 | 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 **不可改、可删**;手动平仓自动结束滚仓监控
|
||||||
Reference in New Issue
Block a user