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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-26 22:03:23 +08:00
parent 4aebe70611
commit d467760d5c
17 changed files with 1506 additions and 630 deletions
+317 -125
View File
@@ -1,17 +1,29 @@
"""滚仓挂单监控:斐波限价止盈侧突破撤单、成交同步、活跃组结案(各所共用)。"""
"""滚仓程序监控:斐波/突破触价市价成交、失效、外部平仓同步(各所共用)。"""
from __future__ import annotations
from typing import Any, Optional
from fib_key_monitor_lib import fib_invalidate_by_mark
from strategy_roll_lib import unified_stop_from_avg
from strategy_roll_lib import (
BREAKOUT_MODE,
FIB_MODES,
MARKET_MODE,
mode_label,
roll_breakout_invalidate,
roll_breakout_trigger_crossed,
roll_fib_invalidate,
roll_fib_trigger_crossed,
calc_risk_budget_usdt,
max_roll_legs,
preview_roll,
solve_add_amount_for_total_risk,
)
from strategy_db import init_strategy_tables
ROLL_LEG_STATUS_LABELS = {
"pending": "挂单",
"pending": "监控",
"filled": "已成交",
"cancelled": "撤销",
"invalidated": "止盈侧突破失效",
"cancelled": "删除",
"invalidated": "失效",
}
@@ -40,6 +52,97 @@ def check_roll_monitors(cfg: dict[str, Any]) -> None:
pass
def sync_roll_after_external_close(
cfg: dict, conn, symbol: str, direction: str, *, reason: str = "持仓已平"
) -> dict[str, Any]:
"""中控/实例手动平仓后:取消 pending 腿并关闭 active 滚仓组(保留 filled 历史)。"""
norm = cfg.get("normalize_symbol_input")
sym = norm(symbol) if callable(norm) else (symbol or "").strip()
if not sym:
return {"ok": False, "msg": "symbol 无效", "closed_groups": 0, "cancelled_legs": 0}
direction = (direction or "long").strip().lower()
init_strategy_tables(conn)
rows = conn.execute(
"""SELECT g.* FROM roll_groups g
WHERE g.status='active' AND g.symbol=? AND g.direction=?""",
(sym, direction),
).fetchall()
closed = cancelled = 0
for row in rows:
g = _row_dict(row)
cancelled += _cancel_pending_legs_for_group(conn, cfg, g, status="cancelled")
cur = conn.execute(
"UPDATE roll_groups SET status='closed', updated_at=? WHERE id=? AND status='active'",
(_now(cfg), int(g["id"])),
)
if getattr(cur, "rowcount", 0):
closed += 1
try:
from strategy_wechat_notify import notify_roll_group_ended
notify_roll_group_ended(
cfg,
group_id=int(g["id"]),
symbol=sym,
direction=direction,
reason=reason,
leg_count=int(g.get("leg_count") or 0),
)
except Exception:
pass
try:
from strategy_snapshot_lib import save_roll_group_snapshot
save_roll_group_snapshot(cfg, conn, g, result_label="结束")
except Exception:
pass
return {
"ok": True,
"symbol": sym,
"direction": direction,
"closed_groups": closed,
"cancelled_legs": cancelled,
}
def cancel_roll_pending_leg(cfg: dict, conn, leg_id: int) -> tuple[bool, str]:
"""用户删除 pending 滚仓腿(不可修改,仅删除)。"""
init_strategy_tables(conn)
row = conn.execute(
"SELECT l.*, g.symbol, g.direction, g.status AS group_status FROM roll_legs l "
"INNER JOIN roll_groups g ON g.id = l.roll_group_id WHERE l.id=?",
(int(leg_id),),
).fetchone()
if not row:
return False, "滚仓腿不存在"
leg = _row_dict(row)
if (leg.get("status") or "").strip().lower() != "pending":
return False, "仅监控中的腿可删除"
_cancel_roll_leg_order(cfg, {"symbol": leg.get("symbol"), "exchange_symbol": leg.get("exchange_symbol")}, leg)
conn.execute(
"UPDATE roll_legs SET status='cancelled' WHERE id=? AND status='pending'",
(int(leg_id),),
)
conn.commit()
return True, "已删除滚仓监控"
def count_filled_roll_legs(conn, roll_group_id: int) -> int:
row = conn.execute(
"SELECT COUNT(*) FROM roll_legs WHERE roll_group_id=? AND status='filled'",
(int(roll_group_id),),
).fetchone()
return int(row[0] if row else 0)
def count_pending_roll_legs(conn, roll_group_id: int) -> int:
row = conn.execute(
"SELECT COUNT(*) FROM roll_legs WHERE roll_group_id=? AND status='pending'",
(int(roll_group_id),),
).fetchone()
return int(row[0] if row else 0)
def _row_dict(row) -> dict:
if row is None:
return {}
@@ -54,25 +157,26 @@ def _now(cfg: dict) -> str:
return fn() if callable(fn) else ""
def _close_roll_group(
conn,
cfg: dict,
group: dict,
*,
cancel_pending: bool = True,
) -> None:
def _cancel_pending_legs_for_group(conn, cfg: dict, group: dict, *, status: str = "cancelled") -> int:
gid = int(group["id"])
if cancel_pending:
for leg in conn.execute(
"SELECT * FROM roll_legs WHERE roll_group_id=? AND status='pending'",
(gid,),
).fetchall():
ld = _row_dict(leg)
_cancel_roll_leg_order(cfg, group, ld)
conn.execute(
"UPDATE roll_legs SET status='cancelled' WHERE id=? AND status='pending'",
(ld["id"],),
)
n = 0
for leg in conn.execute(
"SELECT * FROM roll_legs WHERE roll_group_id=? AND status='pending'",
(gid,),
).fetchall():
ld = _row_dict(leg)
_cancel_roll_leg_order(cfg, group, ld)
conn.execute(
"UPDATE roll_legs SET status=? WHERE id=? AND status='pending'",
(status, ld["id"]),
)
n += 1
return n
def _close_roll_group(conn, cfg: dict, group: dict, *, reason: str = "下单监控已结案或交易所无同向持仓") -> None:
gid = int(group["id"])
_cancel_pending_legs_for_group(conn, cfg, group, status="cancelled")
cur = conn.execute(
"UPDATE roll_groups SET status='closed', updated_at=? WHERE id=? AND status='active'",
(_now(cfg), gid),
@@ -81,7 +185,6 @@ def _close_roll_group(
try:
from strategy_wechat_notify import notify_roll_group_ended
reason = "下单监控已结案或交易所无同向持仓"
notify_roll_group_ended(
cfg,
group_id=gid,
@@ -116,7 +219,7 @@ def _reconcile_roll_groups(conn, cfg: dict) -> None:
pos = cfg["get_position"](ex_sym, direction)
qty = float(pos.get("contracts") or 0)
if not mon_ok or qty <= 0:
_close_roll_group(conn, cfg, g, cancel_pending=True)
_close_roll_group(conn, cfg, g)
def _cancel_roll_leg_order(cfg: dict, group: dict, leg: dict) -> None:
@@ -133,10 +236,35 @@ def _cancel_roll_leg_order(cfg: dict, group: dict, leg: dict) -> None:
pass
def _contract_size(cfg: dict, ex_sym: str) -> float:
get_cs = cfg.get("get_contract_size")
if callable(get_cs):
try:
return float(get_cs(ex_sym) or 1.0)
except Exception:
pass
return 1.0
def _resolve_add_mode(leg: dict) -> str:
raw = (leg.get("add_mode") or "").strip().lower()
if raw in (MARKET_MODE, "market", "市价", "市价加仓"):
return MARKET_MODE
if "786" in raw or raw == "fib_786":
return "fib_786"
if "618" in raw or raw == "fib_618":
return "fib_618"
if raw in (BREAKOUT_MODE, "突破", "突破加仓"):
return BREAKOUT_MODE
if raw.startswith("fib"):
return raw.replace(".", "_").replace("0.", "0")
return raw or MARKET_MODE
def _check_pending_roll_legs(conn, cfg: dict) -> None:
rows = conn.execute(
"""SELECT l.*, g.symbol, g.exchange_symbol, g.direction, g.initial_take_profit,
g.order_monitor_id
g.order_monitor_id, g.risk_percent, g.leg_count
FROM roll_legs l
INNER JOIN roll_groups g ON g.id = l.roll_group_id AND g.status='active'
WHERE l.status='pending'"""
@@ -150,6 +278,8 @@ def _check_pending_roll_legs(conn, cfg: dict) -> None:
"direction": leg["direction"],
"initial_take_profit": leg["initial_take_profit"],
"order_monitor_id": leg["order_monitor_id"],
"risk_percent": leg.get("risk_percent"),
"leg_count": leg.get("leg_count"),
}
_process_pending_roll_leg(conn, cfg, group, leg)
@@ -158,56 +288,51 @@ def _process_pending_roll_leg(conn, cfg: dict, group: dict, leg: dict) -> None:
symbol = group.get("symbol") or ""
direction = (group.get("direction") or "long").strip().lower()
ex_sym = group.get("exchange_symbol") or cfg["normalize_exchange_symbol"](symbol)
oid = (leg.get("exchange_order_id") or "").strip()
mark_fn = cfg.get("get_mark_price") or cfg.get("get_price")
mark = mark_fn(symbol) if callable(mark_fn) else None
if mark is None:
return
mark_f = float(mark)
prev_mark = leg.get("last_mark_price")
try:
prev_f = float(prev_mark) if prev_mark not in (None, "") else None
except (TypeError, ValueError):
prev_f = None
order_status_fn = cfg.get("limit_order_status")
order_st = order_status_fn(ex_sym, oid) if callable(order_status_fn) and oid else "missing"
mode = _resolve_add_mode(leg)
sl = float(leg.get("new_stop_loss") or 0)
fib_u, fib_l = leg.get("fib_upper"), leg.get("fib_lower")
has_fib = fib_u is not None and fib_l is not None
bp = leg.get("breakthrough_price")
if order_st == "filled":
_finalize_roll_leg_fill(conn, cfg, group, leg, ex_sym, direction, float(mark))
return
if mode in FIB_MODES and fib_u is not None and fib_l is not None:
if roll_fib_invalidate(direction, mark_f, float(fib_u), float(fib_l)):
_invalidate_roll_leg(conn, cfg, group, leg, mark_f, reason="止盈侧突破")
return
elif mode == BREAKOUT_MODE and sl > 0:
if roll_breakout_invalidate(direction, mark_f, sl):
_invalidate_roll_leg(conn, cfg, group, leg, mark_f, reason="止损侧突破")
return
if has_fib and fib_invalidate_by_mark(direction, mark, fib_u, fib_l):
if order_st == "open":
_cancel_roll_leg_order(cfg, group, leg)
_invalidate_roll_leg(conn, cfg, group, leg, float(mark))
return
triggered = False
if mode in FIB_MODES:
lp = leg.get("limit_price")
if lp is not None and roll_fib_trigger_crossed(direction, prev_f, mark_f, float(lp)):
triggered = True
elif mode == BREAKOUT_MODE and bp is not None:
if roll_breakout_trigger_crossed(direction, prev_f, mark_f, float(bp)):
triggered = True
if order_st in ("canceled", "missing", "unknown") and has_fib:
if fib_invalidate_by_mark(direction, mark, fib_u, fib_l):
_invalidate_roll_leg(conn, cfg, group, leg, float(mark))
def _invalidate_roll_leg(
conn, cfg: dict, group: dict, leg: dict, mark: float
) -> None:
leg_id = int(leg["id"])
gid = int(group["id"])
cur = conn.execute(
"SELECT status FROM roll_legs WHERE id=?", (leg_id,)
).fetchone()
if not cur or (cur[0] or "").strip().lower() == "invalidated":
return
conn.execute(
"UPDATE roll_legs SET status='invalidated' WHERE id=? AND status='pending'",
(leg_id,),
"UPDATE roll_legs SET last_mark_price=? WHERE id=? AND status='pending'",
(mark_f, int(leg["id"])),
)
conn.execute(
"""UPDATE roll_groups SET leg_count = CASE WHEN leg_count > 0 THEN leg_count - 1 ELSE 0 END,
updated_at=? WHERE id=?""",
(_now(cfg), gid),
)
_send_roll_invalidate_wechat(cfg, group, leg, mark)
if triggered:
_execute_pending_roll_leg(conn, cfg, group, leg, ex_sym, direction, mark_f)
return
def _finalize_roll_leg_fill(
def _execute_pending_roll_leg(
conn,
cfg: dict,
group: dict,
@@ -217,94 +342,161 @@ def _finalize_roll_leg_fill(
mark: float,
) -> None:
leg_id = int(leg["id"])
gid = int(group["id"])
new_sl = float(leg.get("new_stop_loss") or 0)
stop_offset_pct = leg.get("stop_offset_pct")
tp0 = float(group.get("initial_take_profit") or 0)
fill_px = float(leg.get("limit_price") or mark)
add_qty = float(leg.get("amount") or 0)
if stop_offset_pct not in (None, ""):
try:
offset_pct = float(stop_offset_pct)
except (TypeError, ValueError):
offset_pct = 0.0
if offset_pct > 0:
pos = cfg["get_position"](ex_sym, direction) or {}
avg = float(pos.get("entry_price") or 0)
if avg <= 0 and add_qty > 0:
avg = fill_px
if avg > 0:
new_sl = unified_stop_from_avg(direction, avg, offset_pct)
px_fn = cfg.get("price_to_precision")
if callable(px_fn):
try:
new_sl = float(px_fn(ex_sym, new_sl) or new_sl)
except Exception:
pass
conn.execute(
"UPDATE roll_legs SET status='filled', fill_price=?, new_stop_loss=? WHERE id=? AND status='pending'",
(fill_px, new_sl, leg_id),
)
if new_sl > 0:
conn.execute(
"UPDATE roll_groups SET current_stop_loss=?, updated_at=? WHERE id=?",
(new_sl, _now(cfg), gid),
)
gid = int(group["roll_group_id"]) if "roll_group_id" in leg else int(group["id"])
mon_id = group.get("order_monitor_id")
if mon_id and new_sl > 0:
conn.execute(
"UPDATE order_monitors SET stop_loss=? WHERE id=? AND status='active'",
(new_sl, mon_id),
mon = None
if mon_id:
row = conn.execute("SELECT * FROM order_monitors WHERE id=?", (mon_id,)).fetchone()
mon = _row_dict(row) if row else None
if not mon or (mon.get("status") or "").strip().lower() != "active":
_invalidate_roll_leg(conn, cfg, group, leg, mark, reason="监控单已失效")
return
pos = cfg["get_position"](ex_sym, direction) or {}
qty = float(pos.get("contracts") or 0)
entry = float(pos.get("entry_price") or mon.get("trigger_price") or 0)
if qty <= 0 or entry <= 0:
_invalidate_roll_leg(conn, cfg, group, leg, mark, reason="无持仓")
return
filled = count_filled_roll_legs(conn, gid)
if filled >= max_roll_legs(direction):
_invalidate_roll_leg(conn, cfg, group, leg, mark, reason="滚仓次数已满")
return
try:
risk_pct = float(mon.get("risk_percent") or group.get("risk_percent") or 2)
except (TypeError, ValueError):
risk_pct = 2.0
conn_cap = cfg["get_db"]()
try:
capital = float(cfg["get_trading_capital_usdt"](conn_cap))
finally:
conn_cap.close()
cs = _contract_size(cfg, ex_sym)
sl = float(leg.get("new_stop_loss") or 0)
tp0 = float(group.get("initial_take_profit") or mon.get("take_profit") or 0)
mode = _resolve_add_mode(leg)
q2_raw, err = solve_add_amount_for_total_risk(
direction, qty, entry, mark, sl, calc_risk_budget_usdt(capital, risk_pct), cs
)
if err or q2_raw is None or float(q2_raw) <= 0:
_invalidate_roll_leg(conn, cfg, group, leg, mark, reason=err or "无法计算加仓张数")
return
amount = cfg["amount_to_precision"](ex_sym, float(q2_raw))
if amount is None or float(amount) <= 0:
_invalidate_roll_leg(conn, cfg, group, leg, mark, reason="加仓张数低于交易所最小精度")
return
lev_fn = cfg.get("default_leverage")
if not callable(lev_fn):
lev_fn = lambda _s: 5
leverage = int(lev_fn(group.get("symbol") or ""))
try:
order = cfg["market_add"](ex_sym, direction, float(amount), leverage)
fill = float(
cfg.get("resolve_fill_price", lambda o, s, p: p)(order, ex_sym, mark) or mark
)
replace = cfg.get("replace_tpsl")
if callable(replace) and new_sl > 0 and tp0 > 0:
mon = None
if mon_id:
row = conn.execute(
"SELECT * FROM order_monitors WHERE id=?", (mon_id,)
).fetchone()
mon = _row_dict(row) if row else None
try:
replace(ex_sym, direction, new_sl, tp0, mon)
except Exception:
pass
except Exception as e:
fe = cfg.get("friendly_error")
msg = fe(e) if callable(fe) else str(e)
_notify_roll_fail(cfg, group, leg, mark, msg)
return
oid = str(order.get("id") or "") if isinstance(order, dict) else ""
cfg["replace_tpsl"](ex_sym, direction, sl, tp0, mon)
conn.execute(
"""UPDATE roll_legs SET status='filled', fill_price=?, amount=?, exchange_order_id=?,
new_stop_loss=? WHERE id=? AND status='pending'""",
(fill, float(amount), oid, sl, leg_id),
)
conn.execute(
"UPDATE roll_groups SET leg_count=?, current_stop_loss=?, updated_at=? WHERE id=?",
(filled + 1, sl, _now(cfg), gid),
)
conn.execute(
"UPDATE order_monitors SET stop_loss=? WHERE id=? AND status='active'",
(sl, mon["id"]),
)
notify = cfg.get("send_wechat")
if callable(notify):
sym = group.get("symbol") or ""
mode = leg.get("add_mode") or "限价"
mode_lbl = leg.get("add_mode") or mode_label(mode)
fmt = cfg.get("format_price")
px_txt = fmt(sym, fill_px) if callable(fmt) else str(fill_px)
sl_txt = fmt(sym, new_sl) if callable(fmt) else str(new_sl)
px_txt = fmt(sym, fill) if callable(fmt) else str(fill)
sl_txt = fmt(sym, sl) if callable(fmt) else str(sl)
acct = _wechat_account(cfg)
dir_txt = _wechat_dir(cfg, direction)
notify(
f"# ✅ {sym} 滚仓限价已成交\n"
f"# ✅ {sym} 滚仓触价成交\n"
f"**账户:{acct}**\n"
f"- 方式:{mode}{dir_txt}\n"
f"- 成交价:{px_txt}新止损{sl_txt}\n"
f"- 交易所止损已尝试同步(止盈仍为首仓)\n"
f"- 方式:{mode_lbl}{dir_txt}\n"
f"- 成交价:{px_txt}张数{amount}\n"
f"- 新止损:{sl_txt}(止盈仍为首仓)\n"
)
def _invalidate_roll_leg(
conn,
cfg: dict,
group: dict,
leg: dict,
mark: float,
*,
reason: str = "",
) -> None:
leg_id = int(leg["id"])
cur = conn.execute("SELECT status FROM roll_legs WHERE id=?", (leg_id,)).fetchone()
if not cur or (cur[0] or "").strip().lower() in ("invalidated", "filled", "cancelled"):
return
_cancel_roll_leg_order(cfg, group, leg)
conn.execute(
"UPDATE roll_legs SET status='invalidated' WHERE id=? AND status='pending'",
(leg_id,),
)
_send_roll_invalidate_wechat(cfg, group, leg, mark, reason=reason)
def _notify_roll_fail(cfg: dict, group: dict, leg: dict, mark: float, reason: str) -> None:
notify = cfg.get("send_wechat")
if not callable(notify):
return
sym = group.get("symbol") or ""
mode = leg.get("add_mode") or "滚仓"
acct = _wechat_account(cfg)
notify(
f"# ❌ {sym} 滚仓触价成交失败\n"
f"**账户:{acct}**\n"
f"- 方式:{mode}\n"
f"- 原因:{reason}\n"
)
def _send_roll_invalidate_wechat(
cfg: dict, group: dict, leg: dict, mark: float
cfg: dict, group: dict, leg: dict, mark: float, *, reason: str = ""
) -> None:
notify = cfg.get("send_wechat")
if not callable(notify):
return
sym = group.get("symbol") or ""
direction = (group.get("direction") or "long").strip().lower()
mode = leg.get("add_mode") or "斐波限价"
mode = leg.get("add_mode") or "滚仓监控"
fmt = cfg.get("format_price")
mark_txt = fmt(sym, mark) if callable(fmt) else str(mark)
acct = _wechat_account(cfg)
dir_txt = _wechat_dir(cfg, direction)
detail = reason or "条件不满足"
notify(
f"# ⚠️ {sym} 滚仓斐波挂单失效\n"
f"# ⚠️ {sym} 滚仓监控失效\n"
f"**账户:{acct}**\n"
f"- 方式:{mode}{dir_txt}\n"
f"- 标记价 {mark_txt} 已触达止盈侧(未成交),已撤限价加仓单\n"
f"- 本条滚仓腿已结案,可继续下一档或重新挂单\n"
f"- 标记价 {mark_txt}{detail}\n"
f"- 本条监控已结案,可重新提交\n"
)