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:
|
try:
|
||||||
expire_old_plans()
|
expire_old_plans()
|
||||||
check_key_monitors()
|
check_key_monitors()
|
||||||
|
fn_roll = getattr(app, "_check_roll_monitors", None)
|
||||||
|
if fn_roll:
|
||||||
|
fn_roll()
|
||||||
check_order_plans()
|
check_order_plans()
|
||||||
fn = getattr(app, "_check_trend_plans", None)
|
fn = getattr(app, "_check_trend_plans", None)
|
||||||
if fn:
|
if fn:
|
||||||
|
|||||||
+385
-105
@@ -74,7 +74,23 @@ from risk.account_risk_lib import (
|
|||||||
trading_day_label,
|
trading_day_label,
|
||||||
)
|
)
|
||||||
from strategy.strategy_db import init_strategy_tables
|
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_snapshot_lib import list_snapshots, save_snapshot
|
||||||
from strategy.strategy_trend_lib import (
|
from strategy.strategy_trend_lib import (
|
||||||
compute_trend_plan_futures,
|
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
|
"""UPDATE trade_order_monitors SET
|
||||||
symbol=?, symbol_name=?, market_code=?, lots=?, entry_price=?,
|
symbol=?, symbol_name=?, market_code=?, lots=?, entry_price=?,
|
||||||
stop_loss=?, take_profit=?, initial_stop_loss=?, trailing_be=?, open_time=?,
|
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=?""",
|
WHERE id=?""",
|
||||||
(
|
(
|
||||||
sym,
|
sym,
|
||||||
@@ -869,6 +885,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
|
|||||||
status_val,
|
status_val,
|
||||||
vt_val,
|
vt_val,
|
||||||
order_px,
|
order_px,
|
||||||
|
get_risk_percent(get_setting),
|
||||||
mid,
|
mid,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -883,8 +900,8 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
|
|||||||
"""INSERT INTO trade_order_monitors (
|
"""INSERT INTO trade_order_monitors (
|
||||||
symbol, symbol_name, market_code, direction, lots, entry_price,
|
symbol, symbol_name, market_code, direction, lots, entry_price,
|
||||||
stop_loss, take_profit, initial_stop_loss, trailing_be,
|
stop_loss, take_profit, initial_stop_loss, trailing_be,
|
||||||
open_time, monitor_type, status, vt_order_id, order_price
|
open_time, monitor_type, status, vt_order_id, order_price, risk_percent
|
||||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
(
|
(
|
||||||
sym,
|
sym,
|
||||||
codes.get("name", sym),
|
codes.get("name", sym),
|
||||||
@@ -901,6 +918,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
|
|||||||
status_val,
|
status_val,
|
||||||
vt_order_id,
|
vt_order_id,
|
||||||
order_px,
|
order_px,
|
||||||
|
get_risk_percent(get_setting),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
mid = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0])
|
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
|
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")
|
@app.route("/strategy")
|
||||||
@login_required
|
@login_required
|
||||||
@_nav("strategy")
|
@_nav("strategy")
|
||||||
def strategy_page():
|
def strategy_page():
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
init_strategy_tables(conn)
|
init_strategy_tables(conn)
|
||||||
|
ensure_monitor_order_columns(conn)
|
||||||
capital = _capital(conn)
|
capital = _capital(conn)
|
||||||
active_trend = conn.execute(
|
active_trend = conn.execute(
|
||||||
"SELECT * FROM trend_pullback_plans WHERE status='active' ORDER BY id DESC LIMIT 1"
|
"SELECT * FROM trend_pullback_plans WHERE status='active' ORDER BY id DESC LIMIT 1"
|
||||||
).fetchone()
|
).fetchone()
|
||||||
monitors = conn.execute(
|
monitors_raw = conn.execute(
|
||||||
"SELECT * FROM trade_order_monitors WHERE status='active' ORDER BY id DESC"
|
"SELECT * FROM trade_order_monitors WHERE status='active' ORDER BY id DESC"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
roll_groups = conn.execute(
|
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()
|
).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
|
active_trend_row = dict(active_trend) if active_trend else None
|
||||||
if active_trend_row:
|
if active_trend_row:
|
||||||
active_trend_row["period_label"] = trend_period_label(active_trend_row.get("period") or "15m")
|
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(
|
return render_template(
|
||||||
"strategy.html",
|
"strategy.html",
|
||||||
capital=capital,
|
capital=capital,
|
||||||
risk_percent=get_risk_percent(get_setting),
|
fixed_amount=get_fixed_amount(get_setting),
|
||||||
sizing_mode=get_sizing_mode(get_setting),
|
sizing_mode=sizing,
|
||||||
|
sizing_mode_label=_sizing_mode_label(sizing),
|
||||||
|
roll_allowed=roll_allowed,
|
||||||
active_trend=active_trend_row,
|
active_trend=active_trend_row,
|
||||||
monitors=[dict(m) for m in monitors],
|
monitors=monitors,
|
||||||
roll_groups=[dict(g) for g in roll_groups],
|
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(),
|
trend_periods=trend_strategy_periods(),
|
||||||
|
add_mode_labels={
|
||||||
|
"market": "市价加仓",
|
||||||
|
"breakout": "突破加仓",
|
||||||
|
},
|
||||||
|
roll_leg_status_labels={
|
||||||
|
"pending": "监控中",
|
||||||
|
"filled": "已成交",
|
||||||
|
"cancelled": "已删除",
|
||||||
|
"invalidated": "已失效",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.route("/strategy/records")
|
@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']}手")
|
send_wechat_msg(f"趋势回调首仓 {sym} {plan['first_lots']}手")
|
||||||
return jsonify({"ok": True, "plan_id": plan_id, "plan": plan})
|
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(
|
def _apply_roll_margin_cap(
|
||||||
preview: dict,
|
preview: dict,
|
||||||
*,
|
*,
|
||||||
@@ -2780,37 +3102,25 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
|
|||||||
def api_roll_preview():
|
def api_roll_preview():
|
||||||
d = request.get_json(silent=True) or {}
|
d = request.get_json(silent=True) or {}
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
|
init_strategy_tables(conn)
|
||||||
|
ensure_monitor_order_columns(conn)
|
||||||
mon_id = int(d.get("monitor_id") or 0)
|
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(
|
||||||
conn.close()
|
"SELECT * FROM trade_order_monitors WHERE id=? AND status='active'", (mon_id,),
|
||||||
|
).fetchone()
|
||||||
if not mon:
|
if not mon:
|
||||||
|
conn.close()
|
||||||
return jsonify({"ok": False, "error": "无有效持仓监控"}), 400
|
return jsonify({"ok": False, "error": "无有效持仓监控"}), 400
|
||||||
sym = mon["symbol"]
|
mon_d = dict(mon)
|
||||||
spec = get_contract_spec(sym)
|
err = _roll_eligibility(conn, mon_d)
|
||||||
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),
|
|
||||||
)
|
|
||||||
if err:
|
if err:
|
||||||
|
conn.close()
|
||||||
return jsonify({"ok": False, "error": err}), 400
|
return jsonify({"ok": False, "error": err}), 400
|
||||||
preview, merr = _apply_roll_margin_cap(
|
mode = get_trading_mode(get_setting)
|
||||||
preview, conn=conn, mode=get_trading_mode(get_setting), mon=dict(mon), capital=capital,
|
preview, perr = _build_roll_preview(conn, d, mon_d, mode=mode)
|
||||||
)
|
conn.close()
|
||||||
if merr:
|
if perr:
|
||||||
return jsonify({"ok": False, "error": merr}), 400
|
return jsonify({"ok": False, "error": perr}), 400
|
||||||
return jsonify({"ok": True, "preview": preview})
|
return jsonify({"ok": True, "preview": preview})
|
||||||
|
|
||||||
@app.route("/api/strategy/roll/execute", methods=["POST"])
|
@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 {}
|
d = request.get_json(silent=True) or {}
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
init_strategy_tables(conn)
|
init_strategy_tables(conn)
|
||||||
|
ensure_monitor_order_columns(conn)
|
||||||
mon_id = int(d.get("monitor_id") or 0)
|
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:
|
if not mon:
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify({"ok": False, "error": "无有效持仓监控"}), 400
|
return jsonify({"ok": False, "error": "无有效持仓监控"}), 400
|
||||||
if conn.execute("SELECT id FROM trend_pullback_plans WHERE status='active'").fetchone():
|
mon_d = dict(mon)
|
||||||
conn.close()
|
err = _roll_eligibility(conn, mon_d)
|
||||||
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"]),
|
|
||||||
)
|
|
||||||
if err:
|
if err:
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify({"ok": False, "error": err}), 400
|
return jsonify({"ok": False, "error": err}), 400
|
||||||
prev, merr = _apply_roll_margin_cap(
|
mode = get_trading_mode(get_setting)
|
||||||
prev, conn=conn, mode=mode, mon=dict(mon), capital=capital,
|
preview, perr = _build_roll_preview(conn, d, mon_d, mode=mode)
|
||||||
)
|
if perr:
|
||||||
if merr:
|
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify({"ok": False, "error": merr}), 400
|
return jsonify({"ok": False, "error": perr}), 400
|
||||||
price = float(prev["add_price"])
|
add_mode = (d.get("add_mode") or ADD_MODE_MARKET).strip().lower()
|
||||||
try:
|
if add_mode in PENDING_MODES:
|
||||||
execute_order(
|
ok, msg = _submit_roll_pending(conn, mon=mon_d, preview=preview, add_mode=add_mode)
|
||||||
conn, mode=mode, offset="open", symbol=sym,
|
|
||||||
direction=mon["direction"], lots=int(prev["add_lots"]), price=price, settings=_settings_dict(),
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
if not ok:
|
||||||
new_lots = int(mon["lots"]) + int(prev["add_lots"])
|
return jsonify({"ok": False, "error": msg}), 400
|
||||||
new_avg = prev["avg_entry_after"]
|
return jsonify({"ok": True, "message": msg, "pending": True})
|
||||||
new_sl = prev["new_stop_loss"]
|
if not is_trading_session():
|
||||||
conn.execute(
|
conn.close()
|
||||||
"UPDATE trade_order_monitors SET lots=?, entry_price=?, stop_loss=? WHERE id=?",
|
return jsonify({"ok": False, "error": "不在交易时间段"}), 403
|
||||||
(new_lots, new_avg, new_sl, mon_id),
|
if not ctp_status(mode).get("connected"):
|
||||||
)
|
conn.close()
|
||||||
grp = conn.execute(
|
return jsonify({"ok": False, "error": "请先连接 CTP"}), 400
|
||||||
"SELECT * FROM roll_groups WHERE order_monitor_id=? AND status='active'",
|
ok, msg = _commit_roll_fill(
|
||||||
(mon_id,),
|
conn, mon=mon_d, preview=preview, add_mode=add_mode, mode=mode,
|
||||||
).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.close()
|
||||||
|
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.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify({"ok": True, "preview": prev})
|
if not ok:
|
||||||
|
return jsonify({"ok": False, "error": msg}), 400
|
||||||
|
return jsonify({"ok": True, "message": msg})
|
||||||
|
|
||||||
@app.route("/api/strategy/trend/stop", methods=["POST"])
|
@app.route("/api/strategy/trend/stop", methods=["POST"])
|
||||||
@login_required
|
@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) {
|
function formatRoll(preview) {
|
||||||
if (!preview) return '';
|
if (!preview) return '';
|
||||||
var lines = [];
|
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.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.new_stop_loss != null) lines.push('新止损:' + preview.new_stop_loss);
|
||||||
if (preview.total_lots != null) lines.push('合计手数:' + preview.total_lots);
|
if (preview.trigger_price != null) lines.push('触发价:' + preview.trigger_price);
|
||||||
if (preview.worst_loss != null) lines.push('最坏亏损:' + preview.worst_loss + ' 元');
|
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) {
|
if (preview.margin_usage_pct != null) {
|
||||||
lines.push('滚仓后保证金占用:' + preview.margin_usage_pct + '%');
|
lines.push('滚仓后保证金占用:' + preview.margin_usage_pct + '%');
|
||||||
}
|
}
|
||||||
if (preview.margin_cap_note) lines.push(preview.margin_cap_note);
|
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);
|
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 trendForm = document.getElementById('trend-form');
|
||||||
var btnPreview = document.getElementById('btn-trend-preview');
|
var btnPreview = document.getElementById('btn-trend-preview');
|
||||||
var btnExec = document.getElementById('btn-trend-exec');
|
var btnExec = document.getElementById('btn-trend-exec');
|
||||||
@@ -140,10 +193,20 @@
|
|||||||
var btnRollP = document.getElementById('btn-roll-preview');
|
var btnRollP = document.getElementById('btn-roll-preview');
|
||||||
var btnRollE = document.getElementById('btn-roll-exec');
|
var btnRollE = document.getElementById('btn-roll-exec');
|
||||||
var rollPrev = document.getElementById('roll-preview');
|
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) {
|
if (btnRollP && rollForm) {
|
||||||
btnRollP.addEventListener('click', function () {
|
btnRollP.addEventListener('click', function () {
|
||||||
btnRollP.disabled = true;
|
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) {
|
if (!d.ok) {
|
||||||
showPreview(rollPrev, d.error, false, false);
|
showPreview(rollPrev, d.error, false, false);
|
||||||
btnRollE.hidden = true;
|
btnRollE.hidden = true;
|
||||||
@@ -158,18 +221,37 @@
|
|||||||
}
|
}
|
||||||
if (btnRollE && rollForm) {
|
if (btnRollE && rollForm) {
|
||||||
btnRollE.addEventListener('click', function () {
|
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.disabled = true;
|
||||||
btnRollE.textContent = '执行中…';
|
btnRollE.textContent = '提交中…';
|
||||||
jsonPost('/api/strategy/roll/execute', formData(rollForm)).then(function (d) {
|
jsonPost('/api/strategy/roll/execute', payload).then(function (d) {
|
||||||
if (!d.ok) { alert(d.error); return; }
|
if (!d.ok) { alert(d.error || '失败'); return; }
|
||||||
|
alert(d.message || '已提交监控');
|
||||||
location.reload();
|
location.reload();
|
||||||
}).finally(function () {
|
}).finally(function () {
|
||||||
btnRollE.disabled = false;
|
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');
|
var btnStop = document.getElementById('btn-trend-stop');
|
||||||
if (btnStop) {
|
if (btnStop) {
|
||||||
btnStop.addEventListener('click', function () {
|
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
|
_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'")
|
conn.execute("ALTER TABLE trend_pullback_plans ADD COLUMN period TEXT DEFAULT '15m'")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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():
|
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.execute("INSERT INTO ctp_sim_account (id, balance, available) VALUES (1, 100000, 100000)")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|||||||
+219
-24
@@ -3,25 +3,49 @@
|
|||||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||||
|
|
||||||
"""顺势加仓(滚仓):纯计算,期货版(手数整数、乘数计入盈亏)。"""
|
"""顺势加仓(滚仓):纯计算与校验,期货版(手数整数、乘数计入盈亏)。"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import math
|
import math
|
||||||
from typing import Any, Optional, Tuple
|
from typing import Any, Optional, Tuple
|
||||||
|
|
||||||
|
from position_sizing import MODE_AMOUNT
|
||||||
from strategy.fib_lib import calc_fib_plan
|
from strategy.fib_lib import calc_fib_plan
|
||||||
|
|
||||||
ROLL_MAX_LEGS_LONG = 3
|
ROLL_MAX_LEGS_LONG = 3
|
||||||
ROLL_MAX_LEGS_SHORT = 3
|
ROLL_MAX_LEGS_SHORT = 3
|
||||||
ROLL_STOP_OFFSET_PCT_DEFAULT = 1.0
|
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]:
|
def fib_ratio_from_mode(mode: str) -> Optional[float]:
|
||||||
m = (mode or "").strip().lower()
|
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
|
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 0.786
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -75,6 +99,7 @@ def solve_add_lots_for_total_risk(
|
|||||||
risk_budget: float,
|
risk_budget: float,
|
||||||
mult: int,
|
mult: int,
|
||||||
) -> Tuple[Optional[int], Optional[str]]:
|
) -> 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)
|
q1, e1, e2, sl, b = float(qty_existing), float(entry_existing), float(add_price), float(new_stop), float(risk_budget)
|
||||||
m = float(mult)
|
m = float(mult)
|
||||||
direction = (direction or "long").strip().lower()
|
direction = (direction or "long").strip().lower()
|
||||||
@@ -89,10 +114,142 @@ def solve_add_lots_for_total_risk(
|
|||||||
q2 = numer / denom
|
q2 = numer / denom
|
||||||
lots = lots_precise(q2)
|
lots = lots_precise(q2)
|
||||||
if lots < 1:
|
if lots < 1:
|
||||||
return None, "按总风险%无需再加仓或无法再加"
|
return None, "已满足风险上限或无法再加"
|
||||||
return lots, 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(
|
def preview_roll(
|
||||||
*,
|
*,
|
||||||
direction: str,
|
direction: str,
|
||||||
@@ -102,39 +259,70 @@ def preview_roll(
|
|||||||
initial_take_profit: float,
|
initial_take_profit: float,
|
||||||
add_mode: str,
|
add_mode: str,
|
||||||
new_stop_loss: float,
|
new_stop_loss: float,
|
||||||
risk_percent: float,
|
risk_budget: float,
|
||||||
capital_base: float,
|
|
||||||
mult: int,
|
mult: int,
|
||||||
|
mark_price: Optional[float] = None,
|
||||||
add_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_upper: Optional[float] = None,
|
||||||
fib_lower: Optional[float] = None,
|
fib_lower: Optional[float] = None,
|
||||||
legs_done: int = 0,
|
legs_done: int = 0,
|
||||||
|
at_trigger: bool = False,
|
||||||
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
|
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
|
||||||
direction = (direction or "long").strip().lower()
|
direction = (direction or "long").strip().lower()
|
||||||
if legs_done >= max_roll_legs(direction):
|
if legs_done >= max_roll_legs(direction):
|
||||||
return None, f"滚仓已达 {max_roll_legs(direction)} 次上限"
|
return None, f"滚仓已达 {max_roll_legs(direction)} 次上限"
|
||||||
mode = (add_mode or "market").strip().lower()
|
mode = (add_mode or ADD_MODE_MARKET).strip().lower()
|
||||||
if mode == "market":
|
mark = float(mark_price or add_price or 0)
|
||||||
if not add_price or add_price <= 0:
|
if mark <= 0:
|
||||||
return None, "需要有效参考价"
|
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, "加仓方式无效"
|
|
||||||
sl = float(new_stop_loss)
|
sl = float(new_stop_loss)
|
||||||
tp = float(initial_take_profit)
|
tp = float(initial_take_profit)
|
||||||
if sl <= 0 or tp <= 0:
|
if sl <= 0 or tp <= 0:
|
||||||
return None, "止损/止盈无效"
|
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(
|
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:
|
if err:
|
||||||
return None, err
|
return None, err
|
||||||
@@ -150,15 +338,22 @@ def preview_roll(
|
|||||||
return {
|
return {
|
||||||
"symbol": symbol,
|
"symbol": symbol,
|
||||||
"direction": direction,
|
"direction": direction,
|
||||||
|
"add_mode": mode,
|
||||||
"add_mode_label": mode_label,
|
"add_mode_label": mode_label,
|
||||||
|
"is_pending": is_pending,
|
||||||
"add_price": round(entry_add, 4),
|
"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),
|
"new_stop_loss": round(sl, 4),
|
||||||
"initial_take_profit": tp,
|
"initial_take_profit": tp,
|
||||||
"risk_percent": float(risk_percent),
|
"risk_budget": round(budget, 2),
|
||||||
|
"fixed_amount": round(budget, 2),
|
||||||
"add_lots": q2,
|
"add_lots": q2,
|
||||||
"qty_after": int(new_qty),
|
"qty_after": int(new_qty),
|
||||||
"avg_entry_after": round(new_avg, 4),
|
"avg_entry_after": round(new_avg, 4),
|
||||||
"loss_at_sl": round(loss_at_sl, 2),
|
"loss_at_sl": round(loss_at_sl, 2),
|
||||||
"reward_at_tp": round(reward_at_tp, 2),
|
"reward_at_tp": round(reward_at_tp, 2),
|
||||||
"legs_done": legs_done,
|
"legs_done": legs_done,
|
||||||
|
"mark_price": round(mark, 4),
|
||||||
}, None
|
}, 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"])),
|
||||||
|
)
|
||||||
+90
-20
@@ -32,9 +32,11 @@
|
|||||||
</ul>
|
</ul>
|
||||||
<p><strong>顺势加仓(滚仓)</strong></p>
|
<p><strong>顺势加仓(滚仓)</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>在「下单监控」已有 <strong>active 持仓监控</strong> 上扩大仓位,统一抬高止损</li>
|
<li>仅<strong>固定金额(以损定仓)</strong>模式;<strong>移动保本</strong>持仓不可滚仓</li>
|
||||||
<li>预览后执行;<strong>仓位上限冻结时仍可滚仓</strong>,但受滚仓保证金上限约束</li>
|
<li>须「下单监控」有 active 持仓;与趋势回调互斥</li>
|
||||||
<li>最多 3 腿;止盈锁定首仓逻辑</li>
|
<li>风险预算 = 系统设置<strong>固定金额</strong>;合并持仓打到新止损总亏损 ≤ B</li>
|
||||||
|
<li>最多 3 腿(已成交);同一组最多 1 条 pending 监控腿</li>
|
||||||
|
<li>止盈锁定首仓;突破由程序监控标记价穿越后市价加仓</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
@@ -87,43 +89,111 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>顺势加仓(滚仓)</h2>
|
<h2>顺势加仓(滚仓)</h2>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p class="hint" style="margin-bottom:.65rem">在已有持仓上扩大仓位,统一抬高止损;最多 3 腿,止盈锁定首仓。</p>
|
<details class="module-rules" style="margin-bottom:.65rem">
|
||||||
{% if roll_groups %}
|
<summary>顺势加仓规则说明</summary>
|
||||||
{% for g in roll_groups %}
|
<div class="module-rules-body" style="font-size:.78rem;line-height:1.55">
|
||||||
<div class="strategy-active-roll">
|
<ul>
|
||||||
运行中 · 监控 #{{ g.order_monitor_id }} · {{ g.leg_count or 1 }} 腿 · 止损 {{ g.current_stop_loss }}
|
<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>
|
</div>
|
||||||
{% endfor %}
|
</details>
|
||||||
|
{% if not roll_allowed %}
|
||||||
|
<p class="hint text-muted">当前为「{{ sizing_mode_label }}」模式,滚仓不可用。请在系统设置切换为<strong>固定金额</strong>。</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if monitors %}
|
{% 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">
|
<form id="roll-form" class="form-compact">
|
||||||
<div class="field" style="margin-bottom:.5rem">
|
<div class="form-line line-2">
|
||||||
<label class="text-label" style="font-size:.72rem">选择下单监控(开仓后生成)</label>
|
<select name="monitor_id" id="roll-monitor-select" required {% if not roll_allowed %}disabled{% endif %}>
|
||||||
<select name="monitor_id" required>
|
|
||||||
{% for m in monitors %}
|
{% 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 %}
|
{% endfor %}
|
||||||
</select>
|
</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>
|
||||||
<div class="form-line line-2">
|
<div class="form-line line-2">
|
||||||
<input name="new_stop_loss" type="number" step="any" placeholder="新统一止损" required>
|
<input name="new_stop_loss" id="roll-new-sl" type="number" step="any" placeholder="新统一止损" required {% if not roll_allowed %}disabled{% endif %}>
|
||||||
<input name="risk_percent" type="number" step="0.1" value="2" placeholder="总风险 %" title="总风险%">
|
<input name="breakthrough_price" id="roll-break-price" type="number" step="any" placeholder="突破价" hidden>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-line line-2">
|
<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" {% if not roll_allowed %}disabled{% endif %}>预览</button>
|
||||||
<button type="button" class="btn-primary" id="btn-roll-preview">预览滚仓</button>
|
<button type="button" class="btn-primary" id="btn-roll-exec" hidden {% if not roll_allowed %}disabled{% endif %}>执行滚仓</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="roll-preview" class="strategy-preview" hidden></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>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="empty-hint">暂无可用持仓监控</p>
|
<p class="empty-hint">暂无可用持仓监控</p>
|
||||||
<ol class="strategy-steps">
|
<ol class="strategy-steps">
|
||||||
<li>打开 <a href="{{ url_for('positions') }}">持仓监控</a>,连接 CTP</li>
|
<li>打开 <a href="{{ url_for('positions') }}">持仓监控</a>,连接 CTP</li>
|
||||||
<li>在「期货下单」填写品种、止损/止盈并<strong>开仓</strong></li>
|
<li>系统设置为<strong>固定金额</strong>,在「期货下单」开仓(勿开移动保本)</li>
|
||||||
<li>开仓成功后会生成本页可选的监控记录,即可滚仓</li>
|
<li>开仓成功后生成本页可选监控,即可滚仓</li>
|
||||||
</ol>
|
</ol>
|
||||||
{% endif %}
|
{% 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user