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
+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