Add futures roll strategy with breakout monitoring and fixed-amount sizing.

Replace percent-based risk with system fixed amount, support market/breakout add modes only, allow pending submission outside trading hours, and fix short breakout geometry plus route registration.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-29 12:05:21 +08:00
parent 7ce59d2d71
commit 44bec23296
8 changed files with 982 additions and 160 deletions
+3
View File
@@ -705,6 +705,9 @@ def background_task():
try:
expire_old_plans()
check_key_monitors()
fn_roll = getattr(app, "_check_roll_monitors", None)
if fn_roll:
fn_roll()
check_order_plans()
fn = getattr(app, "_check_trend_plans", None)
if fn:
+386 -106
View File
@@ -74,7 +74,23 @@ from risk.account_risk_lib import (
trading_day_label,
)
from strategy.strategy_db import init_strategy_tables
from strategy.strategy_roll_lib import avg_entry_after_add, preview_roll
from strategy.strategy_roll_lib import (
ADD_MODE_BREAKOUT,
ADD_MODE_MARKET,
FIB_MODES,
LEG_STATUS_FILLED,
LEG_STATUS_PENDING,
PENDING_MODES,
add_mode_label,
avg_entry_after_add,
preview_roll,
roll_eligibility_error,
)
from strategy.strategy_roll_monitor_lib import (
cancel_roll_leg,
check_roll_monitors,
roll_sync_after_external_close,
)
from strategy.strategy_snapshot_lib import list_snapshots, save_snapshot
from strategy.strategy_trend_lib import (
compute_trend_plan_futures,
@@ -852,7 +868,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
"""UPDATE trade_order_monitors SET
symbol=?, symbol_name=?, market_code=?, lots=?, entry_price=?,
stop_loss=?, take_profit=?, initial_stop_loss=?, trailing_be=?, open_time=?,
monitor_type=?, status=?, vt_order_id=?, order_price=?
monitor_type=?, status=?, vt_order_id=?, order_price=?, risk_percent=COALESCE(risk_percent, ?)
WHERE id=?""",
(
sym,
@@ -869,6 +885,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
status_val,
vt_val,
order_px,
get_risk_percent(get_setting),
mid,
),
)
@@ -883,8 +900,8 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
"""INSERT INTO trade_order_monitors (
symbol, symbol_name, market_code, direction, lots, entry_price,
stop_loss, take_profit, initial_stop_loss, trailing_be,
open_time, monitor_type, status, vt_order_id, order_price
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
open_time, monitor_type, status, vt_order_id, order_price, risk_percent
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
sym,
codes.get("name", sym),
@@ -901,6 +918,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
status_val,
vt_order_id,
order_px,
get_risk_percent(get_setting),
),
)
mid = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0])
@@ -2083,22 +2101,73 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
return jsonify({"ok": False, "error": str(exc)}), 400
def _roll_ui_modes():
return frozenset({ADD_MODE_MARKET, ADD_MODE_BREAKOUT})
def _enrich_roll_group_row(row: dict) -> dict:
out = dict(row)
lots = float(out.get("mon_lots") or 0)
entry = float(out.get("mon_entry") or 0)
tp = float(out.get("mon_tp") or out.get("initial_take_profit") or 0)
direction = (out.get("direction") or "long").strip().lower()
sym = (out.get("symbol") or "").strip()
mult = int(get_contract_spec(sym).get("mult") or 1) if sym else 1
out["avg_entry"] = round(entry, 4) if entry > 0 else None
if lots > 0 and entry > 0 and tp > 0:
if direction == "long":
out["reward_at_tp"] = round((tp - entry) * lots * mult, 2)
else:
out["reward_at_tp"] = round((entry - tp) * lots * mult, 2)
else:
out["reward_at_tp"] = None
return out
def _roll_leg_trigger_price(leg: dict):
for key in ("breakthrough_price", "limit_price", "fill_price"):
val = leg.get(key)
if val not in (None, "", 0):
return val
return None
@app.route("/strategy")
@login_required
@_nav("strategy")
def strategy_page():
conn = get_db()
init_strategy_tables(conn)
ensure_monitor_order_columns(conn)
capital = _capital(conn)
active_trend = conn.execute(
"SELECT * FROM trend_pullback_plans WHERE status='active' ORDER BY id DESC LIMIT 1"
).fetchone()
monitors = conn.execute(
monitors_raw = conn.execute(
"SELECT * FROM trade_order_monitors WHERE status='active' ORDER BY id DESC"
).fetchall()
roll_groups = conn.execute(
"SELECT * FROM roll_groups WHERE status='active' ORDER BY id DESC"
"""SELECT g.*, m.symbol_name, m.lots AS mon_lots, m.entry_price AS mon_entry,
m.take_profit AS mon_tp
FROM roll_groups g
LEFT JOIN trade_order_monitors m ON m.id = g.order_monitor_id
WHERE g.status='active' ORDER BY g.id DESC"""
).fetchall()
roll_legs = conn.execute(
"""SELECT l.*, g.symbol, g.direction, g.order_monitor_id
FROM roll_legs l
JOIN roll_groups g ON g.id = l.roll_group_id
ORDER BY l.id DESC LIMIT 30"""
).fetchall()
sizing = get_sizing_mode(get_setting)
roll_allowed = sizing == MODE_AMOUNT
monitors = []
for m in monitors_raw:
row = dict(m)
err = _roll_eligibility(conn, row)
row["roll_eligible"] = roll_allowed and err is None
if not roll_allowed:
row["roll_block_reason"] = "仅固定金额(以损定仓)模式可滚仓"
else:
row["roll_block_reason"] = err or ""
monitors.append(row)
active_trend_row = dict(active_trend) if active_trend else None
if active_trend_row:
active_trend_row["period_label"] = trend_period_label(active_trend_row.get("period") or "15m")
@@ -2106,12 +2175,25 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
return render_template(
"strategy.html",
capital=capital,
risk_percent=get_risk_percent(get_setting),
sizing_mode=get_sizing_mode(get_setting),
fixed_amount=get_fixed_amount(get_setting),
sizing_mode=sizing,
sizing_mode_label=_sizing_mode_label(sizing),
roll_allowed=roll_allowed,
active_trend=active_trend_row,
monitors=[dict(m) for m in monitors],
roll_groups=[dict(g) for g in roll_groups],
monitors=monitors,
roll_groups=[_enrich_roll_group_row(dict(g)) for g in roll_groups],
roll_legs=[dict(l) for l in roll_legs],
trend_periods=trend_strategy_periods(),
add_mode_labels={
"market": "市价加仓",
"breakout": "突破加仓",
},
roll_leg_status_labels={
"pending": "监控中",
"filled": "已成交",
"cancelled": "已删除",
"invalidated": "已失效",
},
)
@app.route("/strategy/records")
@@ -2724,6 +2806,246 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
send_wechat_msg(f"趋势回调首仓 {sym} {plan['first_lots']}")
return jsonify({"ok": True, "plan_id": plan_id, "plan": plan})
def _roll_group_for_monitor(conn, monitor_id: int):
return conn.execute(
"SELECT * FROM roll_groups WHERE order_monitor_id=? AND status='active'",
(int(monitor_id),),
).fetchone()
def _roll_filled_legs(conn, monitor_id: int) -> int:
grp = _roll_group_for_monitor(conn, monitor_id)
if grp:
return int(grp["leg_count"] or 0)
return 0
def _roll_has_pending(conn, monitor_id: int) -> bool:
grp = _roll_group_for_monitor(conn, monitor_id)
if not grp:
return False
return bool(conn.execute(
"SELECT 1 FROM roll_legs WHERE roll_group_id=? AND status=? LIMIT 1",
(int(grp["id"]), LEG_STATUS_PENDING),
).fetchone())
def _roll_eligibility(conn, mon: dict) -> Optional[str]:
has_trend = bool(conn.execute(
"SELECT 1 FROM trend_pullback_plans WHERE status='active' LIMIT 1",
).fetchone())
return roll_eligibility_error(
sizing_mode=get_sizing_mode(get_setting),
monitor=mon,
has_active_trend=has_trend,
legs_done=_roll_filled_legs(conn, int(mon["id"])),
has_pending_leg=_roll_has_pending(conn, int(mon["id"])),
)
def _roll_mark_price(sym: str, mon: dict, mode: str) -> float:
mark = ctp_get_tick_price(mode, sym) if ctp_status(mode).get("connected") else None
if mark and mark > 0:
return float(mark)
px = fetch_price(sym)
if px and px > 0:
return float(px)
return float(mon.get("entry_price") or 0)
def _build_roll_preview(conn, d: dict, mon: dict, *, mode: str):
sym = mon["symbol"]
spec = get_contract_spec(sym)
capital = _capital(conn)
mark = _roll_mark_price(sym, mon, mode)
add_mode = (d.get("add_mode") or ADD_MODE_MARKET).strip().lower()
if add_mode in FIB_MODES:
return None, "斐波加仓已停用,请选市价或突破"
if add_mode not in _roll_ui_modes():
return None, "仅支持市价加仓或突破加仓"
risk_budget = get_fixed_amount(get_setting)
legs_done = _roll_filled_legs(conn, int(mon["id"]))
preview, err = preview_roll(
direction=mon["direction"],
symbol=sym,
qty_existing=float(mon["lots"]),
entry_existing=float(mon["entry_price"]),
initial_take_profit=float(mon["take_profit"] or 0),
add_mode=add_mode,
new_stop_loss=float(d.get("new_stop_loss") or 0),
risk_budget=risk_budget,
mult=int(spec["mult"]),
mark_price=mark,
add_price=float(d.get("add_price") or 0) or mark,
limit_price=d.get("limit_price"),
breakthrough_price=d.get("breakthrough_price"),
fib_upper=d.get("fib_upper"),
fib_lower=d.get("fib_lower"),
legs_done=legs_done,
)
if err:
return None, err
preview, merr = _apply_roll_margin_cap(
preview, conn=conn, mode=mode, mon=dict(mon), capital=capital,
)
if merr:
return None, merr
return preview, None
def _commit_roll_fill(
conn,
*,
mon: dict,
preview: dict,
add_mode: str,
mode: str,
pending_leg_id: Optional[int] = None,
) -> tuple[bool, str]:
sym = mon["symbol"]
mon_id = int(mon["id"])
price = float(preview["add_price"])
try:
execute_order(
conn, mode=mode, offset="open", symbol=sym,
direction=mon["direction"], lots=int(preview["add_lots"]), price=price,
settings=_settings_dict(),
)
except ValueError as exc:
return False, str(exc)
new_lots = int(mon["lots"]) + int(preview["add_lots"])
new_avg = preview["avg_entry_after"]
new_sl = preview["new_stop_loss"]
conn.execute(
"UPDATE trade_order_monitors SET lots=?, entry_price=?, stop_loss=? WHERE id=?",
(new_lots, new_avg, new_sl, mon_id),
)
grp = _roll_group_for_monitor(conn, mon_id)
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
risk_budget = float(preview.get("risk_budget") or get_fixed_amount(get_setting))
if grp:
gid = int(grp["id"])
leg_n = int(grp["leg_count"] or 0) + 1
conn.execute(
"UPDATE roll_groups SET leg_count=?, current_stop_loss=?, updated_at=? WHERE id=?",
(leg_n, new_sl, now, gid),
)
else:
cur = conn.execute(
"""INSERT INTO roll_groups (
order_monitor_id, symbol, direction, initial_take_profit, initial_stop_loss,
current_stop_loss, risk_percent, leg_count, status, created_at, updated_at
) VALUES (?,?,?,?,?,?,?,1,'active',?,?)""",
(
mon_id, sym, mon["direction"], mon["take_profit"], mon["stop_loss"],
new_sl, risk_budget, now, now,
),
)
gid = int(cur.lastrowid)
leg_n = 1
if pending_leg_id:
conn.execute(
"""UPDATE roll_legs SET status=?, fill_price=?, lots=?, new_stop_loss=?, created_at=?
WHERE id=?""",
(
LEG_STATUS_FILLED, price, int(preview["add_lots"]), new_sl, now,
int(pending_leg_id),
),
)
else:
conn.execute(
"""INSERT INTO roll_legs (
roll_group_id, leg_index, add_mode, fill_price, lots, new_stop_loss,
status, created_at, limit_price, breakthrough_price, last_mark_price, capital_snapshot
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""",
(
gid, leg_n, add_mode, price, int(preview["add_lots"]), new_sl,
LEG_STATUS_FILLED, now,
preview.get("limit_price"), preview.get("breakthrough_price"),
preview.get("mark_price"), _capital(conn),
),
)
conn.commit()
send_wechat_msg(
f"滚仓成交 {sym} {add_mode_label(add_mode)} +{preview['add_lots']}"
f"新止损 {new_sl} 合计 {new_lots}"
)
return True, "成交"
def _submit_roll_pending(
conn,
*,
mon: dict,
preview: dict,
add_mode: str,
) -> tuple[bool, str]:
mon_id = int(mon["id"])
grp = _roll_group_for_monitor(conn, mon_id)
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
capital = _capital(conn)
risk_budget = float(preview.get("risk_budget") or get_fixed_amount(get_setting))
if grp:
gid = int(grp["id"])
else:
cur = conn.execute(
"""INSERT INTO roll_groups (
order_monitor_id, symbol, direction, initial_take_profit, initial_stop_loss,
current_stop_loss, risk_percent, leg_count, status, created_at, updated_at
) VALUES (?,?,?,?,?,?,?,0,'active',?,?)""",
(
mon_id, mon["symbol"], mon["direction"], mon["take_profit"], mon["stop_loss"],
preview["new_stop_loss"], risk_budget, now, now,
),
)
gid = int(cur.lastrowid)
leg_n = int(conn.execute(
"SELECT COUNT(*) AS n FROM roll_legs WHERE roll_group_id=? AND status=?",
(gid, LEG_STATUS_FILLED),
).fetchone()["n"]) + 1
pending_n = conn.execute(
"SELECT COUNT(*) AS n FROM roll_legs WHERE roll_group_id=? AND status=?",
(gid, LEG_STATUS_PENDING),
).fetchone()["n"]
if int(pending_n or 0) > 0:
return False, "已有监控中的加仓腿"
conn.execute(
"""INSERT INTO roll_legs (
roll_group_id, leg_index, add_mode, lots, new_stop_loss, status, created_at,
limit_price, breakthrough_price, last_mark_price, capital_snapshot
) VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
(
gid, leg_n, add_mode, int(preview["add_lots"]), preview["new_stop_loss"],
LEG_STATUS_PENDING, now,
preview.get("limit_price"), preview.get("breakthrough_price"),
preview.get("mark_price"), capital,
),
)
conn.commit()
return True, "已提交监控,触价后自动市价加仓"
def _fill_roll_leg_cb(mon: dict, grp: dict, leg: dict, preview: dict) -> tuple[bool, str]:
conn = get_db()
init_strategy_tables(conn)
mode = get_trading_mode(get_setting)
ok, msg = _commit_roll_fill(
conn, mon=mon, preview=preview, add_mode=leg.get("add_mode") or ADD_MODE_MARKET,
mode=mode, pending_leg_id=int(leg["id"]),
)
conn.close()
return ok, msg
def _check_roll_monitors():
conn = get_db()
init_strategy_tables(conn)
mode = get_trading_mode(get_setting)
try:
check_roll_monitors(
conn,
get_mark_price_fn=lambda sym: _roll_mark_price(sym, {}, mode),
fill_roll_leg_fn=_fill_roll_leg_cb,
is_trading_session_fn=is_trading_session,
get_risk_budget_fn=lambda: get_fixed_amount(get_setting),
)
conn.commit()
finally:
conn.close()
app._check_roll_monitors = _check_roll_monitors
def _apply_roll_margin_cap(
preview: dict,
*,
@@ -2780,37 +3102,25 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
def api_roll_preview():
d = request.get_json(silent=True) or {}
conn = get_db()
init_strategy_tables(conn)
ensure_monitor_order_columns(conn)
mon_id = int(d.get("monitor_id") or 0)
mon = conn.execute("SELECT * FROM trade_order_monitors WHERE id=? AND status='active'", (mon_id,)).fetchone()
conn.close()
mon = conn.execute(
"SELECT * FROM trade_order_monitors WHERE id=? AND status='active'", (mon_id,),
).fetchone()
if not mon:
conn.close()
return jsonify({"ok": False, "error": "无有效持仓监控"}), 400
sym = mon["symbol"]
spec = get_contract_spec(sym)
capital = _capital(get_db())
preview, err = preview_roll(
direction=mon["direction"],
symbol=sym,
qty_existing=float(mon["lots"]),
entry_existing=float(mon["entry_price"]),
initial_take_profit=float(mon["take_profit"] or 0),
add_mode=d.get("add_mode") or "market",
new_stop_loss=float(d.get("new_stop_loss") or 0),
risk_percent=float(d.get("risk_percent") or 2),
capital_base=capital,
mult=spec["mult"],
add_price=float(d.get("add_price") or mon["entry_price"]),
fib_upper=d.get("fib_upper"),
fib_lower=d.get("fib_lower"),
legs_done=int(d.get("legs_done") or 0),
)
mon_d = dict(mon)
err = _roll_eligibility(conn, mon_d)
if err:
conn.close()
return jsonify({"ok": False, "error": err}), 400
preview, merr = _apply_roll_margin_cap(
preview, conn=conn, mode=get_trading_mode(get_setting), mon=dict(mon), capital=capital,
)
if merr:
return jsonify({"ok": False, "error": merr}), 400
mode = get_trading_mode(get_setting)
preview, perr = _build_roll_preview(conn, d, mon_d, mode=mode)
conn.close()
if perr:
return jsonify({"ok": False, "error": perr}), 400
return jsonify({"ok": True, "preview": preview})
@app.route("/api/strategy/roll/execute", methods=["POST"])
@@ -2819,87 +3129,57 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
d = request.get_json(silent=True) or {}
conn = get_db()
init_strategy_tables(conn)
ensure_monitor_order_columns(conn)
mon_id = int(d.get("monitor_id") or 0)
mon = conn.execute("SELECT * FROM trade_order_monitors WHERE id=? AND status='active'", (mon_id,)).fetchone()
mon = conn.execute(
"SELECT * FROM trade_order_monitors WHERE id=? AND status='active'", (mon_id,),
).fetchone()
if not mon:
conn.close()
return jsonify({"ok": False, "error": "无有效持仓监控"}), 400
if conn.execute("SELECT id FROM trend_pullback_plans WHERE status='active'").fetchone():
conn.close()
return jsonify({"ok": False, "error": "趋势回调运行中,不可滚仓"}), 400
sym = mon["symbol"]
spec = get_contract_spec(sym)
capital = _capital(conn)
mode = get_trading_mode(get_setting)
prev, err = preview_roll(
direction=mon["direction"],
symbol=sym,
qty_existing=float(mon["lots"]),
entry_existing=float(mon["entry_price"]),
initial_take_profit=float(mon["take_profit"] or 0),
add_mode=d.get("add_mode") or "market",
new_stop_loss=float(d.get("new_stop_loss") or 0),
risk_percent=float(d.get("risk_percent") or 2),
capital_base=capital,
mult=spec["mult"],
add_price=float(d.get("add_price") or mon["entry_price"]),
)
mon_d = dict(mon)
err = _roll_eligibility(conn, mon_d)
if err:
conn.close()
return jsonify({"ok": False, "error": err}), 400
prev, merr = _apply_roll_margin_cap(
prev, conn=conn, mode=mode, mon=dict(mon), capital=capital,
)
if merr:
mode = get_trading_mode(get_setting)
preview, perr = _build_roll_preview(conn, d, mon_d, mode=mode)
if perr:
conn.close()
return jsonify({"ok": False, "error": merr}), 400
price = float(prev["add_price"])
try:
execute_order(
conn, mode=mode, offset="open", symbol=sym,
direction=mon["direction"], lots=int(prev["add_lots"]), price=price, settings=_settings_dict(),
)
except ValueError as exc:
return jsonify({"ok": False, "error": perr}), 400
add_mode = (d.get("add_mode") or ADD_MODE_MARKET).strip().lower()
if add_mode in PENDING_MODES:
ok, msg = _submit_roll_pending(conn, mon=mon_d, preview=preview, add_mode=add_mode)
conn.close()
return jsonify({"ok": False, "error": str(exc)}), 400
new_lots = int(mon["lots"]) + int(prev["add_lots"])
new_avg = prev["avg_entry_after"]
new_sl = prev["new_stop_loss"]
conn.execute(
"UPDATE trade_order_monitors SET lots=?, entry_price=?, stop_loss=? WHERE id=?",
(new_lots, new_avg, new_sl, mon_id),
if not ok:
return jsonify({"ok": False, "error": msg}), 400
return jsonify({"ok": True, "message": msg, "pending": True})
if not is_trading_session():
conn.close()
return jsonify({"ok": False, "error": "不在交易时间段"}), 403
if not ctp_status(mode).get("connected"):
conn.close()
return jsonify({"ok": False, "error": "请先连接 CTP"}), 400
ok, msg = _commit_roll_fill(
conn, mon=mon_d, preview=preview, add_mode=add_mode, mode=mode,
)
grp = conn.execute(
"SELECT * FROM roll_groups WHERE order_monitor_id=? AND status='active'",
(mon_id,),
).fetchone()
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
if grp:
gid = grp["id"]
leg_n = int(grp["leg_count"] or 0) + 1
conn.execute(
"UPDATE roll_groups SET leg_count=?, current_stop_loss=?, updated_at=? WHERE id=?",
(leg_n, new_sl, now, gid),
)
else:
cur = conn.execute(
"""INSERT INTO roll_groups (
order_monitor_id, symbol, direction, initial_take_profit, initial_stop_loss,
current_stop_loss, risk_percent, leg_count, status, created_at, updated_at
) VALUES (?,?,?,?,?,?,?,1,'active',?,?)""",
(mon_id, sym, mon["direction"], mon["take_profit"], mon["stop_loss"], new_sl,
float(d.get("risk_percent") or 2), now, now),
)
gid = cur.lastrowid
leg_n = 1
conn.execute(
"""INSERT INTO roll_legs (roll_group_id, leg_index, add_mode, fill_price, lots, new_stop_loss, status, created_at)
VALUES (?,?,?,?,?,?, 'filled', ?)""",
(gid, leg_n, d.get("add_mode") or "market", price, int(prev["add_lots"]), new_sl, now),
)
conn.commit()
conn.close()
return jsonify({"ok": True, "preview": prev})
if not ok:
return jsonify({"ok": False, "error": msg}), 400
return jsonify({"ok": True, "message": msg, "preview": preview})
@app.route("/api/strategy/roll/cancel/<int:leg_id>", methods=["POST"])
@login_required
def api_roll_cancel(leg_id: int):
conn = get_db()
init_strategy_tables(conn)
ok, msg = cancel_roll_leg(conn, leg_id)
if ok:
conn.commit()
conn.close()
if not ok:
return jsonify({"ok": False, "error": msg}), 400
return jsonify({"ok": True, "message": msg})
@app.route("/api/strategy/trend/stop", methods=["POST"])
@login_required
+26
View File
@@ -0,0 +1,26 @@
"""Deploy roll strategy overhaul."""
import paramiko, sys
from pathlib import Path
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
root = Path(__file__).resolve().parents[1]
files = [
"strategy/strategy_roll_lib.py",
"strategy/strategy_roll_monitor_lib.py",
"strategy/strategy_db.py",
"install_trading.py",
"app.py",
"templates/strategy.html",
"static/js/strategy.js",
]
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect("192.168.8.21", username="root", password="woaini88", timeout=15)
sftp = c.open_sftp()
for rel in files:
sftp.put(str(root / rel), f"/opt/qihuo/{rel.replace(chr(92), '/')}")
print("uploaded", rel)
sftp.close()
_, o, _ = c.exec_command("cd /opt/qihuo && pm2 restart qihuo")
print(o.read().decode("utf-8", errors="replace"))
c.close()
+90 -8
View File
@@ -87,18 +87,71 @@
function formatRoll(preview) {
if (!preview) return '';
var lines = [];
if (preview.add_mode_label) lines.push('方式:' + preview.add_mode_label);
if (preview.add_lots != null) lines.push('加仓手数:' + preview.add_lots);
if (preview.qty_after != null) lines.push('合计手数:' + preview.qty_after);
if (preview.avg_entry_after != null) lines.push('加仓后均价:' + preview.avg_entry_after);
if (preview.new_stop_loss != null) lines.push('新止损:' + preview.new_stop_loss);
if (preview.total_lots != null) lines.push('合计手数' + preview.total_lots);
if (preview.worst_loss != null) lines.push('最坏亏损' + preview.worst_loss + ' 元');
if (preview.trigger_price != null) lines.push('触发价' + preview.trigger_price);
if (preview.risk_budget != null) lines.push('风险预算(固定金额)' + preview.risk_budget + ' 元');
if (preview.loss_at_sl != null) lines.push('打到止损亏损:' + preview.loss_at_sl + ' 元');
if (preview.reward_at_tp != null) lines.push('止盈盈利:' + preview.reward_at_tp + ' 元');
if (preview.margin_usage_pct != null) {
lines.push('滚仓后保证金占用:' + preview.margin_usage_pct + '%');
}
if (preview.margin_cap_note) lines.push(preview.margin_cap_note);
if (preview.message) lines.push(preview.message);
if (preview.is_pending) lines.push('(提交后为程序监控,触价后自动市价加仓)');
return lines.length ? lines.join('\n') : JSON.stringify(preview, null, 2);
}
function syncRollModeUi() {
var modeEl = document.getElementById('roll-add-mode');
var breakEl = document.getElementById('roll-break-price');
var execHint = document.getElementById('roll-exec-hint');
var btnExec = document.getElementById('btn-roll-exec');
if (!modeEl) return;
var mode = modeEl.value || 'market';
var isBreak = mode === 'breakout';
if (breakEl) {
breakEl.hidden = !isBreak;
breakEl.required = isBreak;
}
if (execHint) execHint.hidden = false;
if (btnExec) {
btnExec.textContent = mode === 'market' ? '执行滚仓' : '提交监控';
}
}
function syncRollRiskHint() {
/* 固定金额由服务端渲染在 #roll-risk-budget,切换监控单无需变更 */
}
var rollCountdownTimer = null;
function startRollCountdown(btn, payload) {
var sec = 10;
btn.disabled = true;
function tick() {
btn.textContent = '确认执行 (' + sec + 's)';
if (sec <= 0) {
clearInterval(rollCountdownTimer);
rollCountdownTimer = null;
jsonPost('/api/strategy/roll/execute', payload).then(function (d) {
if (!d.ok) { alert(d.error || d.message || '失败'); btn.disabled = false; btn.textContent = '执行滚仓'; return; }
alert(d.message || '已提交');
location.reload();
}).catch(function () {
btn.disabled = false;
btn.textContent = '执行滚仓';
});
return;
}
sec -= 1;
}
tick();
rollCountdownTimer = setInterval(tick, 1000);
}
var trendForm = document.getElementById('trend-form');
var btnPreview = document.getElementById('btn-trend-preview');
var btnExec = document.getElementById('btn-trend-exec');
@@ -140,10 +193,20 @@
var btnRollP = document.getElementById('btn-roll-preview');
var btnRollE = document.getElementById('btn-roll-exec');
var rollPrev = document.getElementById('roll-preview');
var rollPayload = null;
var rollMonitorSel = document.getElementById('roll-monitor-select');
var rollModeSel = document.getElementById('roll-add-mode');
if (rollModeSel) rollModeSel.addEventListener('change', syncRollModeUi);
if (rollMonitorSel) rollMonitorSel.addEventListener('change', syncRollRiskHint);
syncRollModeUi();
syncRollRiskHint();
if (btnRollP && rollForm) {
btnRollP.addEventListener('click', function () {
btnRollP.disabled = true;
jsonPost('/api/strategy/roll/preview', formData(rollForm)).then(function (d) {
rollPayload = formData(rollForm);
jsonPost('/api/strategy/roll/preview', rollPayload).then(function (d) {
if (!d.ok) {
showPreview(rollPrev, d.error, false, false);
btnRollE.hidden = true;
@@ -158,18 +221,37 @@
}
if (btnRollE && rollForm) {
btnRollE.addEventListener('click', function () {
var payload = rollPayload || formData(rollForm);
var mode = (payload.add_mode || 'market');
if (mode === 'market') {
if (!confirm('确认执行市价滚仓?')) return;
startRollCountdown(btnRollE, payload);
return;
}
btnRollE.disabled = true;
btnRollE.textContent = '执行中…';
jsonPost('/api/strategy/roll/execute', formData(rollForm)).then(function (d) {
if (!d.ok) { alert(d.error); return; }
btnRollE.textContent = '提交中…';
jsonPost('/api/strategy/roll/execute', payload).then(function (d) {
if (!d.ok) { alert(d.error || '失败'); return; }
alert(d.message || '已提交监控');
location.reload();
}).finally(function () {
btnRollE.disabled = false;
btnRollE.textContent = '执行滚仓';
syncRollModeUi();
});
});
}
document.querySelectorAll('.roll-cancel-leg').forEach(function (btn) {
btn.addEventListener('click', function () {
var legId = btn.getAttribute('data-leg-id');
if (!legId || !confirm('删除该监控腿?')) return;
jsonPost('/api/strategy/roll/cancel/' + legId, {}).then(function (d) {
if (!d.ok) { alert(d.error); return; }
location.reload();
});
});
});
var btnStop = document.getElementById('btn-trend-stop');
if (btnStop) {
btnStop.addEventListener('click', function () {
+15
View File
@@ -122,6 +122,16 @@ CREATE TABLE IF NOT EXISTS ctp_sim_positions (
"""
ROLL_LEG_EXTRA_COLUMNS = (
"ALTER TABLE roll_legs ADD COLUMN limit_price REAL",
"ALTER TABLE roll_legs ADD COLUMN breakthrough_price REAL",
"ALTER TABLE roll_legs ADD COLUMN last_mark_price REAL",
"ALTER TABLE roll_legs ADD COLUMN invalidated_reason TEXT",
"ALTER TABLE roll_legs ADD COLUMN capital_snapshot REAL",
"ALTER TABLE trade_order_monitors ADD COLUMN risk_percent REAL",
)
_TABLES_READY = False
@@ -143,6 +153,11 @@ def init_strategy_tables(conn) -> None:
conn.execute("ALTER TABLE trend_pullback_plans ADD COLUMN period TEXT DEFAULT '15m'")
except Exception:
pass
for sql in ROLL_LEG_EXTRA_COLUMNS:
try:
conn.execute(sql)
except Exception:
pass
if not conn.execute("SELECT id FROM ctp_sim_account WHERE id=1").fetchone():
conn.execute("INSERT INTO ctp_sim_account (id, balance, available) VALUES (1, 100000, 100000)")
conn.commit()
+220 -25
View File
@@ -3,25 +3,49 @@
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""顺势加仓(滚仓):纯计算,期货版(手数整数、乘数计入盈亏)。"""
"""顺势加仓(滚仓):纯计算与校验,期货版(手数整数、乘数计入盈亏)。"""
from __future__ import annotations
import math
from typing import Any, Optional, Tuple
from position_sizing import MODE_AMOUNT
from strategy.fib_lib import calc_fib_plan
ROLL_MAX_LEGS_LONG = 3
ROLL_MAX_LEGS_SHORT = 3
ROLL_STOP_OFFSET_PCT_DEFAULT = 1.0
FIB_MODES = frozenset({"fib_618", "fib_786"})
ADD_MODE_MARKET = "market"
ADD_MODE_FIB_618 = "fib_618"
ADD_MODE_FIB_786 = "fib_786"
ADD_MODE_BREAKOUT = "breakout"
FIB_MODES = frozenset({ADD_MODE_FIB_618, ADD_MODE_FIB_786})
PENDING_MODES = frozenset({ADD_MODE_FIB_618, ADD_MODE_FIB_786, ADD_MODE_BREAKOUT})
ADD_MODE_LABELS = {
ADD_MODE_MARKET: "市价加仓",
ADD_MODE_FIB_618: "斐波0.618",
ADD_MODE_FIB_786: "斐波0.786",
ADD_MODE_BREAKOUT: "突破加仓",
}
LEG_STATUS_PENDING = "pending"
LEG_STATUS_FILLED = "filled"
LEG_STATUS_CANCELLED = "cancelled"
LEG_STATUS_INVALIDATED = "invalidated"
def add_mode_label(mode: str) -> str:
return ADD_MODE_LABELS.get((mode or "").strip().lower(), mode or "")
def fib_ratio_from_mode(mode: str) -> Optional[float]:
m = (mode or "").strip().lower()
if m in ("fib_618", "618", "0.618"):
if m in (ADD_MODE_FIB_618, "618", "0.618"):
return 0.618
if m in ("fib_786", "786", "0.786"):
if m in (ADD_MODE_FIB_786, "786", "0.786"):
return 0.786
return None
@@ -75,6 +99,7 @@ def solve_add_lots_for_total_risk(
risk_budget: float,
mult: int,
) -> Tuple[Optional[int], Optional[str]]:
"""方案 C:合并持仓打到新止损 S 时总亏损 ≤ B。"""
q1, e1, e2, sl, b = float(qty_existing), float(entry_existing), float(add_price), float(new_stop), float(risk_budget)
m = float(mult)
direction = (direction or "long").strip().lower()
@@ -89,10 +114,142 @@ def solve_add_lots_for_total_risk(
q2 = numer / denom
lots = lots_precise(q2)
if lots < 1:
return None, "按总风险%无需再加仓或无法再加"
return None, "已满足风险上限或无法再加"
return lots, None
def roll_eligibility_error(
*,
sizing_mode: str,
monitor: dict,
has_active_trend: bool,
legs_done: int = 0,
has_pending_leg: bool = False,
) -> Optional[str]:
if normalize_sizing_mode(sizing_mode) != MODE_AMOUNT:
return "仅固定金额(以损定仓)模式可滚仓"
if has_active_trend:
return "趋势回调运行中,不可滚仓"
if not monitor or (monitor.get("status") or "").strip().lower() != "active":
return "无有效持仓监控"
if int(monitor.get("trailing_be") or 0):
return "移动保本持仓不可滚仓"
direction = (monitor.get("direction") or "long").strip().lower()
if legs_done >= max_roll_legs(direction):
return f"滚仓已达 {max_roll_legs(direction)} 次上限"
if has_pending_leg:
return "已有监控中的加仓腿,请等待成交或删除后再提交"
if int(monitor.get("lots") or 0) < 1:
return "持仓手数为 0"
if not float(monitor.get("take_profit") or 0):
return "首仓须设置止盈(移动保本不可滚仓)"
return None
def normalize_sizing_mode(raw: str) -> str:
from position_sizing import normalize_sizing_mode as _norm
return _norm(raw)
def resolve_risk_percent(monitor: dict, *, default: float) -> float:
try:
rp = float(monitor.get("risk_percent") or 0)
if rp > 0:
return rp
except (TypeError, ValueError):
pass
return float(default)
def validate_roll_geometry(
direction: str,
add_mode: str,
new_stop: float,
*,
mark_price: float,
limit_price: Optional[float] = None,
breakthrough_price: Optional[float] = None,
at_trigger: bool = False,
) -> Optional[str]:
"""几何校验。
做多斐波回调止损 < 触发价 < 当前价
做多突破向上止损 < 突破价 < 当前价
做空斐波反弹当前价 < 触发价 < 止损
做空突破向下突破价 < 当前价 < 止损提交时触发后当前价可 突破价
"""
direction = (direction or "long").strip().lower()
mode = (add_mode or ADD_MODE_MARKET).strip().lower()
sl = float(new_stop)
mark = float(mark_price)
if sl <= 0 or mark <= 0:
return "止损或参考价无效"
if mode == ADD_MODE_MARKET:
if direction == "long" and sl >= mark:
return "做多:新止损须低于当前价"
if direction == "short" and sl <= mark:
return "做空:新止损须高于当前价"
return None
trigger = None
if mode in FIB_MODES:
trigger = float(limit_price or 0)
if trigger <= 0:
return "须填写斐波触发价"
if direction == "long":
if not (sl < trigger < mark):
return "做多斐波:须满足 止损 < 触发价 < 当前价"
else:
if not (mark < trigger < sl):
return "做空斐波:须满足 当前价 < 触发价 < 止损"
return None
if mode == ADD_MODE_BREAKOUT:
trigger = float(breakthrough_price or 0)
if trigger <= 0:
return "须填写突破价"
if at_trigger:
if direction == "long":
if not (sl < trigger <= mark):
return "做多突破:触发时须满足 止损 < 突破价 ≤ 当前价"
else:
if not (trigger < sl and mark < sl):
return "做空突破:触发时须满足 突破价 < 止损且当前价 < 止损"
return None
if direction == "long":
if not (sl < trigger < mark):
return "做多突破:须满足 止损 < 突破价 < 当前价"
else:
if not (trigger < mark < sl):
return "做空突破:须满足 突破价 < 当前价 < 止损"
return None
return "加仓方式无效"
def detect_mark_cross(
direction: str,
add_mode: str,
prev_mark: float,
mark: float,
trigger_price: float,
) -> bool:
"""标记价穿越触发价(上一 tick 与当前 tick 比较)。"""
direction = (direction or "long").strip().lower()
mode = (add_mode or "").strip().lower()
p = float(trigger_price)
prev_m = float(prev_mark)
cur_m = float(mark)
if p <= 0 or prev_m <= 0 or cur_m <= 0:
return False
if mode in FIB_MODES:
if direction == "long":
return prev_m > p and cur_m <= p
return prev_m < p and cur_m >= p
if mode == ADD_MODE_BREAKOUT:
if direction == "long":
return prev_m < p and cur_m >= p
return prev_m > p and cur_m <= p
return False
def preview_roll(
*,
direction: str,
@@ -102,39 +259,70 @@ def preview_roll(
initial_take_profit: float,
add_mode: str,
new_stop_loss: float,
risk_percent: float,
capital_base: float,
risk_budget: float,
mult: int,
mark_price: Optional[float] = None,
add_price: Optional[float] = None,
limit_price: Optional[float] = None,
breakthrough_price: Optional[float] = None,
fib_upper: Optional[float] = None,
fib_lower: Optional[float] = None,
legs_done: int = 0,
at_trigger: bool = False,
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
direction = (direction or "long").strip().lower()
if legs_done >= max_roll_legs(direction):
return None, f"滚仓已达 {max_roll_legs(direction)} 次上限"
mode = (add_mode or "market").strip().lower()
if mode == "market":
if not add_price or add_price <= 0:
return None, "需要有效参考价"
entry_add = float(add_price)
mode_label = "市价"
elif mode in FIB_MODES:
if fib_upper is None or fib_lower is None:
return None, "斐波须填上沿/下沿"
entry_add, err = fib_limit_entry(direction, float(fib_upper), float(fib_lower), mode)
if err:
return None, err
mode_label = "斐波0.618" if "618" in mode else "斐波0.786"
else:
return None, "加仓方式无效"
mode = (add_mode or ADD_MODE_MARKET).strip().lower()
mark = float(mark_price or add_price or 0)
if mark <= 0:
return None, "需要有效参考价"
sl = float(new_stop_loss)
tp = float(initial_take_profit)
if sl <= 0 or tp <= 0:
return None, "止损/止盈无效"
risk_budget = float(capital_base) * float(risk_percent) / 100.0
entry_add = mark
mode_label = add_mode_label(mode)
trigger_price = mark
is_pending = mode in PENDING_MODES
if mode == ADD_MODE_MARKET:
entry_add = mark
elif mode in FIB_MODES:
if limit_price and float(limit_price) > 0:
entry_add = float(limit_price)
trigger_price = entry_add
elif fib_upper is not None and fib_lower is not None:
entry_add, err = fib_limit_entry(direction, float(fib_upper), float(fib_lower), mode)
if err:
return None, err
trigger_price = entry_add
else:
return None, "斐波须填触发价或上沿/下沿"
elif mode == ADD_MODE_BREAKOUT:
if not breakthrough_price or float(breakthrough_price) <= 0:
return None, "须填写突破价"
entry_add = float(breakthrough_price)
trigger_price = entry_add
else:
return None, "加仓方式无效"
geom_err = validate_roll_geometry(
direction, mode, sl,
mark_price=mark,
limit_price=trigger_price if mode in FIB_MODES else None,
breakthrough_price=trigger_price if mode == ADD_MODE_BREAKOUT else None,
at_trigger=at_trigger,
)
if geom_err:
return None, geom_err
budget = float(risk_budget)
if budget <= 0:
return None, "固定金额无效"
q2, err = solve_add_lots_for_total_risk(
direction, qty_existing, entry_existing, entry_add, sl, risk_budget, mult
direction, qty_existing, entry_existing, entry_add, sl, budget, mult,
)
if err:
return None, err
@@ -150,15 +338,22 @@ def preview_roll(
return {
"symbol": symbol,
"direction": direction,
"add_mode": mode,
"add_mode_label": mode_label,
"is_pending": is_pending,
"add_price": round(entry_add, 4),
"trigger_price": round(trigger_price, 4),
"limit_price": round(trigger_price, 4) if mode in FIB_MODES else None,
"breakthrough_price": round(trigger_price, 4) if mode == ADD_MODE_BREAKOUT else None,
"new_stop_loss": round(sl, 4),
"initial_take_profit": tp,
"risk_percent": float(risk_percent),
"risk_budget": round(budget, 2),
"fixed_amount": round(budget, 2),
"add_lots": q2,
"qty_after": int(new_qty),
"avg_entry_after": round(new_avg, 4),
"loss_at_sl": round(loss_at_sl, 2),
"reward_at_tp": round(reward_at_tp, 2),
"legs_done": legs_done,
"mark_price": round(mark, 4),
}, None
+151
View File
@@ -0,0 +1,151 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""顺势滚仓程序监控:突破 pending 腿触价成交、外部平仓同步。"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any, Callable, Optional
from zoneinfo import ZoneInfo
from contract_specs import get_contract_spec
from strategy.strategy_roll_lib import (
ADD_MODE_BREAKOUT,
FIB_MODES,
LEG_STATUS_CANCELLED,
LEG_STATUS_FILLED,
LEG_STATUS_INVALIDATED,
LEG_STATUS_PENDING,
detect_mark_cross,
preview_roll,
)
logger = logging.getLogger(__name__)
TZ = ZoneInfo("Asia/Shanghai")
def _now() -> str:
return datetime.now(TZ).strftime("%Y-%m-%d %H:%M:%S")
def roll_sync_after_external_close(conn, *, monitor_id: int) -> None:
"""手动平仓或监控结案后关闭滚仓组并清除 pending 腿。"""
grp = conn.execute(
"SELECT id FROM roll_groups WHERE order_monitor_id=? AND status='active'",
(int(monitor_id),),
).fetchone()
if not grp:
return
gid = int(grp["id"])
conn.execute(
"UPDATE roll_legs SET status=? WHERE roll_group_id=? AND status=?",
(LEG_STATUS_CANCELLED, gid, LEG_STATUS_PENDING),
)
conn.execute(
"UPDATE roll_groups SET status='closed', updated_at=? WHERE id=?",
(_now(), gid),
)
def cancel_roll_leg(conn, leg_id: int) -> tuple[bool, str]:
row = conn.execute(
"SELECT * FROM roll_legs WHERE id=? AND status=?",
(int(leg_id), LEG_STATUS_PENDING),
).fetchone()
if not row:
return False, "仅可删除监控中的腿"
conn.execute(
"UPDATE roll_legs SET status=? WHERE id=?",
(LEG_STATUS_CANCELLED, int(leg_id)),
)
return True, "已删除"
def check_roll_monitors(
conn,
*,
get_mark_price_fn: Callable[[str], Optional[float]],
fill_roll_leg_fn: Callable[[dict, dict, dict, dict], tuple[bool, str]],
is_trading_session_fn: Callable[[], bool],
get_risk_budget_fn: Callable[[], float],
) -> None:
"""扫描 pending 滚仓腿,标记价穿越则重算手数并市价成交。"""
if not is_trading_session_fn():
return
rows = conn.execute(
"""SELECT l.*, g.order_monitor_id, g.symbol, g.direction, g.initial_take_profit,
g.risk_percent, g.leg_count AS group_leg_count,
m.lots AS mon_lots, m.entry_price AS mon_entry, m.take_profit AS mon_tp,
m.status AS mon_status
FROM roll_legs l
JOIN roll_groups g ON g.id = l.roll_group_id
JOIN trade_order_monitors m ON m.id = g.order_monitor_id
WHERE l.status=? AND g.status='active' AND m.status='active'""",
(LEG_STATUS_PENDING,),
).fetchall()
for raw in rows:
leg = dict(raw)
if (leg.get("mon_status") or "").strip().lower() != "active":
_invalidate_leg(conn, leg, "监控已结束")
continue
sym = (leg.get("symbol") or "").strip()
mark = get_mark_price_fn(sym)
if not mark or mark <= 0:
continue
prev_mark = float(leg.get("last_mark_price") or mark)
mode = (leg.get("add_mode") or "").strip().lower()
trigger = float(leg.get("limit_price") or leg.get("breakthrough_price") or 0)
direction = (leg.get("direction") or "long").strip().lower()
if mode in FIB_MODES or mode == ADD_MODE_BREAKOUT:
if not detect_mark_cross(direction, mode, prev_mark, mark, trigger):
conn.execute(
"UPDATE roll_legs SET last_mark_price=? WHERE id=?",
(float(mark), int(leg["id"])),
)
continue
mon = {
"id": leg["order_monitor_id"],
"symbol": sym,
"direction": direction,
"lots": leg["mon_lots"],
"entry_price": leg["mon_entry"],
"take_profit": leg["mon_tp"] or leg["initial_take_profit"],
}
grp = {
"id": leg["roll_group_id"],
"order_monitor_id": leg["order_monitor_id"],
"leg_count": leg.get("group_leg_count") or 0,
"risk_percent": leg.get("risk_percent"),
}
preview, err = preview_roll(
direction=direction,
symbol=sym,
qty_existing=float(leg["mon_lots"] or 0),
entry_existing=float(leg["mon_entry"] or 0),
initial_take_profit=float(leg["mon_tp"] or leg["initial_take_profit"] or 0),
add_mode=mode,
new_stop_loss=float(leg["new_stop_loss"] or 0),
risk_budget=float(leg.get("risk_percent") or 0) or get_risk_budget_fn(),
mult=int(get_contract_spec(sym).get("mult") or 1),
mark_price=mark,
limit_price=trigger if mode in FIB_MODES else None,
breakthrough_price=trigger if mode == ADD_MODE_BREAKOUT else None,
legs_done=int(leg.get("group_leg_count") or 0),
at_trigger=True,
)
if err or not preview:
_invalidate_leg(conn, leg, err or "触发时无法加仓")
continue
ok, msg = fill_roll_leg_fn(mon, grp, leg, preview)
if not ok:
logger.warning("roll leg fill failed #%s: %s", leg.get("id"), msg)
def _invalidate_leg(conn, leg: dict, reason: str) -> None:
conn.execute(
"UPDATE roll_legs SET status=?, invalidated_reason=? WHERE id=?",
(LEG_STATUS_INVALIDATED, (reason or "")[:200], int(leg["id"])),
)
+91 -21
View File
@@ -32,9 +32,11 @@
</ul>
<p><strong>顺势加仓(滚仓)</strong></p>
<ul>
<li>在「下单监控」已有 <strong>active 持仓监控</strong> 上扩大仓位,统一抬高止损</li>
<li>预览后执行;<strong>仓位上限冻结时仍可滚仓</strong>,但受滚仓保证金上限约束</li>
<li>最多 3 腿;止盈锁定首仓逻辑</li>
<li><strong>固定金额(以损定仓)</strong>模式;<strong>移动保本</strong>持仓不可滚仓</li>
<li>须「下单监控」有 active 持仓;与趋势回调互斥</li>
<li>风险预算 = 系统设置<strong>固定金额</strong>;合并持仓打到新止损总亏损 ≤ B</li>
<li>最多 3 腿(已成交);同一组最多 1 条 pending 监控腿</li>
<li>止盈锁定首仓;突破由程序监控标记价穿越后市价加仓</li>
</ul>
</div>
</details>
@@ -87,43 +89,111 @@
<div class="card">
<h2>顺势加仓(滚仓)</h2>
<div class="card-body">
<p class="hint" style="margin-bottom:.65rem">在已有持仓上扩大仓位,统一抬高止损;最多 3 腿,止盈锁定首仓。</p>
{% if roll_groups %}
{% for g in roll_groups %}
<div class="strategy-active-roll">
运行中 · 监控 #{{ g.order_monitor_id }} · {{ g.leg_count or 1 }} 腿 · 止损 {{ g.current_stop_loss }}
</div>
{% endfor %}
<details class="module-rules" style="margin-bottom:.65rem">
<summary>顺势加仓规则说明</summary>
<div class="module-rules-body" style="font-size:.78rem;line-height:1.55">
<ul>
<li>手动提交;须实盘已有同向持仓与 active 监控单</li>
<li>计仓模式须为<strong>固定金额</strong>;移动保本不可滚仓</li>
<li>做多/做空各最多 3 次滚仓(仅计已成交);止盈为首仓 TP 不变</li>
<li>风险预算 B = 系统设置中的<strong>固定金额</strong>;打到新止损 S 时合并持仓总亏损 ≤ B</li>
<li>突破:标记价穿越触发价后按当时持仓重算手数再市价加仓</li>
<li>pending 腿不可改,只能删除;手动平仓后滚仓组关闭</li>
</ul>
</div>
</details>
{% if not roll_allowed %}
<p class="hint text-muted">当前为「{{ sizing_mode_label }}」模式,滚仓不可用。请在系统设置切换为<strong>固定金额</strong></p>
{% endif %}
{% if monitors %}
<p class="hint" id="roll-risk-hint">风险预算(固定金额):<strong id="roll-risk-budget">{{ '%.0f'|format(fixed_amount) }} 元</strong></p>
<form id="roll-form" class="form-compact">
<div class="field" style="margin-bottom:.5rem">
<label class="text-label" style="font-size:.72rem">选择下单监控(开仓后生成)</label>
<select name="monitor_id" required>
<div class="form-line line-2">
<select name="monitor_id" id="roll-monitor-select" required {% if not roll_allowed %}disabled{% endif %}>
{% for m in monitors %}
<option value="{{ m.id }}">{{ m.symbol_name or m.symbol }} {{ m.symbol }} · {{ '多' if m.direction == 'long' else '空' }} {{ m.lots }}手 · SL {{ m.stop_loss or '—' }}</option>
<option value="{{ m.id }}"
data-direction="{{ m.direction }}"
data-eligible="{{ '1' if m.roll_eligible else '0' }}"
data-block="{{ m.roll_block_reason or '' }}"
{% if not m.roll_eligible %}disabled{% endif %}>
{{ m.symbol_name or m.symbol }} {{ m.symbol }} · {{ '多' if m.direction == 'long' else '空' }} #{{ m.id }}
· {{ m.lots }}手 · SL {{ m.stop_loss or '—' }}
{% if not m.roll_eligible %} · {{ m.roll_block_reason }}{% endif %}
</option>
{% endfor %}
</select>
<select name="add_mode" id="roll-add-mode" {% if not roll_allowed %}disabled{% endif %}>
<option value="market">市价加仓</option>
<option value="breakout">突破加仓</option>
</select>
</div>
<div class="form-line line-2">
<input name="new_stop_loss" type="number" step="any" placeholder="新统一止损" required>
<input name="risk_percent" type="number" step="0.1" value="2" placeholder="总风险 %" title="总风险%">
<input name="new_stop_loss" id="roll-new-sl" type="number" step="any" placeholder="新统一止损" required {% if not roll_allowed %}disabled{% endif %}>
<input name="breakthrough_price" id="roll-break-price" type="number" step="any" placeholder="突破价" hidden>
</div>
<div class="form-line line-2">
<input name="add_price" type="number" step="any" placeholder="加仓参考价(可选)">
<button type="button" class="btn-primary" id="btn-roll-preview">预览滚仓</button>
<button type="button" class="btn-primary" id="btn-roll-preview" {% if not roll_allowed %}disabled{% endif %}>预览</button>
<button type="button" class="btn-primary" id="btn-roll-exec" hidden {% if not roll_allowed %}disabled{% endif %}>执行滚仓</button>
</div>
<div id="roll-preview" class="strategy-preview" hidden></div>
<button type="button" class="btn-primary" id="btn-roll-exec" hidden style="margin-top:.65rem;width:100%">执行滚仓</button>
<p class="hint" id="roll-exec-hint" hidden style="font-size:.75rem;margin-top:.45rem">市价加仓:须交易时段内确认,10 秒倒计时执行;突破加仓:任意时间可提交,开盘后再监控触价</p>
</form>
{% else %}
<p class="empty-hint">暂无可用持仓监控</p>
<ol class="strategy-steps">
<li>打开 <a href="{{ url_for('positions') }}">持仓监控</a>,连接 CTP</li>
<li>在「期货下单」填写品种、止损/止盈并<strong>开仓</strong></li>
<li>开仓成功后生成本页可选监控记录,即可滚仓</li>
<li>系统设置为<strong>固定金额</strong>,在「期货下单」开仓(勿开移动保本)</li>
<li>开仓成功后生成本页可选监控,即可滚仓</li>
</ol>
{% endif %}
<h3 style="font-size:.85rem;margin:1rem 0 .45rem">活跃滚仓组</h3>
{% if roll_groups %}
<table class="strategy-preview-table">
<thead><tr>
<th>ID</th><th>品种</th><th>方向</th><th>腿数</th><th>首仓TP</th><th>当前SL</th><th>当前均价</th><th>止盈盈利(元)</th>
</tr></thead>
<tbody>
{% for g in roll_groups %}
<tr>
<td>#{{ g.id }}</td>
<td>{{ g.symbol_name or g.symbol }}</td>
<td>{{ '多' if g.direction == 'long' else '空' }}</td>
<td>{{ g.leg_count or 0 }}/3</td>
<td>{{ g.initial_take_profit or '—' }}</td>
<td>{{ g.current_stop_loss or '—' }}</td>
<td>{{ g.avg_entry or '—' }}</td>
<td>{{ g.reward_at_tp if g.reward_at_tp is not none else '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="hint text-muted">暂无</p>
{% endif %}
<h3 style="font-size:.85rem;margin:1rem 0 .45rem">最近滚仓腿</h3>
{% if roll_legs %}
<table class="strategy-preview-table">
<thead><tr>
<th>#</th><th></th><th>方式</th><th>手数</th><th>触发/限价</th><th>新SL</th><th>状态</th><th>操作</th>
</tr></thead>
<tbody>
{% for l in roll_legs %}
<tr>
<td>{{ l.id }}</td>
<td>#{{ l.roll_group_id }}</td>
<td>{{ add_mode_labels.get(l.add_mode, l.add_mode) }}</td>
<td>{{ l.lots or '—' }}</td>
<td>{{ l.breakthrough_price or l.limit_price or l.fill_price or '—' }}</td>
<td>{{ l.new_stop_loss or '—' }}</td>
<td title="{{ l.invalidated_reason or '' }}">{{ roll_leg_status_labels.get(l.status, l.status) }}{% if l.status == 'invalidated' and l.invalidated_reason %} · {{ l.invalidated_reason[:24] }}{% endif %}</td>
<td>{% if l.status == 'pending' %}<button type="button" class="btn-link roll-cancel-leg" data-leg-id="{{ l.id }}">删除</button>{% else %}—{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="hint text-muted">暂无</p>
{% endif %}
</div>
</div>
</div>