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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 () {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user