diff --git a/app.py b/app.py index 6ae6640..3265693 100644 --- a/app.py +++ b/app.py @@ -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: diff --git a/install_trading.py b/install_trading.py index d4dc572..5fee70c 100644 --- a/install_trading.py +++ b/install_trading.py @@ -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/", 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 diff --git a/scripts/deploy_roll_strategy.py b/scripts/deploy_roll_strategy.py new file mode 100644 index 0000000..d10574e --- /dev/null +++ b/scripts/deploy_roll_strategy.py @@ -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() diff --git a/static/js/strategy.js b/static/js/strategy.js index 7646314..d79a67d 100644 --- a/static/js/strategy.js +++ b/static/js/strategy.js @@ -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 () { diff --git a/strategy/strategy_db.py b/strategy/strategy_db.py index 7686e18..d08d908 100644 --- a/strategy/strategy_db.py +++ b/strategy/strategy_db.py @@ -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() diff --git a/strategy/strategy_roll_lib.py b/strategy/strategy_roll_lib.py index 243c76f..fc2866c 100644 --- a/strategy/strategy_roll_lib.py +++ b/strategy/strategy_roll_lib.py @@ -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 diff --git a/strategy/strategy_roll_monitor_lib.py b/strategy/strategy_roll_monitor_lib.py new file mode 100644 index 0000000..f605123 --- /dev/null +++ b/strategy/strategy_roll_monitor_lib.py @@ -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"])), + ) diff --git a/templates/strategy.html b/templates/strategy.html index a710c41..205017e 100644 --- a/templates/strategy.html +++ b/templates/strategy.html @@ -32,9 +32,11 @@

顺势加仓(滚仓)

@@ -87,43 +89,111 @@

顺势加仓(滚仓)

-

在已有持仓上扩大仓位,统一抬高止损;最多 3 腿,止盈锁定首仓。

- {% if roll_groups %} - {% for g in roll_groups %} -
- 运行中 · 监控 #{{ g.order_monitor_id }} · {{ g.leg_count or 1 }} 腿 · 止损 {{ g.current_stop_loss }} -
- {% endfor %} +
+ 顺势加仓规则说明 +
+
    +
  • 手动提交;须实盘已有同向持仓与 active 监控单
  • +
  • 计仓模式须为固定金额;移动保本不可滚仓
  • +
  • 做多/做空各最多 3 次滚仓(仅计已成交);止盈为首仓 TP 不变
  • +
  • 风险预算 B = 系统设置中的固定金额;打到新止损 S 时合并持仓总亏损 ≤ B
  • +
  • 突破:标记价穿越触发价后按当时持仓重算手数再市价加仓
  • +
  • pending 腿不可改,只能删除;手动平仓后滚仓组关闭
  • +
+
+
+ {% if not roll_allowed %} +

当前为「{{ sizing_mode_label }}」模式,滚仓不可用。请在系统设置切换为固定金额

{% endif %} {% if monitors %} +

风险预算(固定金额):{{ '%.0f'|format(fixed_amount) }} 元

-
- - {% for m in monitors %} - + {% endfor %} +
- - + +
- - + +
- +
{% else %}

暂无可用持仓监控

  1. 打开 持仓监控,连接 CTP
  2. -
  3. 在「期货下单」填写品种、止损/止盈并开仓
  4. -
  5. 开仓成功后会生成本页可选的监控记录,即可滚仓
  6. +
  7. 系统设置为固定金额,在「期货下单」开仓(勿开移动保本)
  8. +
  9. 开仓成功后生成本页可选监控,即可滚仓
{% endif %} +

活跃滚仓组

+ {% if roll_groups %} + + + + + + {% for g in roll_groups %} + + + + + + + + + + + {% endfor %} + +
ID品种方向腿数首仓TP当前SL当前均价止盈盈利(元)
#{{ g.id }}{{ g.symbol_name or g.symbol }}{{ '多' if g.direction == 'long' else '空' }}{{ g.leg_count or 0 }}/3{{ g.initial_take_profit or '—' }}{{ g.current_stop_loss or '—' }}{{ g.avg_entry or '—' }}{{ g.reward_at_tp if g.reward_at_tp is not none else '—' }}
+ {% else %} +

暂无

+ {% endif %} +

最近滚仓腿

+ {% if roll_legs %} + + + + + + {% for l in roll_legs %} + + + + + + + + + + + {% endfor %} + +
#方式手数触发/限价新SL状态操作
{{ l.id }}#{{ l.roll_group_id }}{{ add_mode_labels.get(l.add_mode, l.add_mode) }}{{ l.lots or '—' }}{{ l.breakthrough_price or l.limit_price or l.fill_price or '—' }}{{ l.new_stop_loss or '—' }}{{ roll_leg_status_labels.get(l.status, l.status) }}{% if l.status == 'invalidated' and l.invalidated_reason %} · {{ l.invalidated_reason[:24] }}{% endif %}{% if l.status == 'pending' %}{% else %}—{% endif %}
+ {% else %} +

暂无

+ {% endif %}