fix: 止盈止损委托校验现价并在平仓后撤余单
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+3
-12
@@ -27,6 +27,7 @@ from sl_tp_guard import (
|
|||||||
ensure_monitor_order_columns,
|
ensure_monitor_order_columns,
|
||||||
monitor_order_status,
|
monitor_order_status,
|
||||||
place_monitor_exit_orders,
|
place_monitor_exit_orders,
|
||||||
|
reconcile_monitors_without_position,
|
||||||
start_sl_tp_guard_worker,
|
start_sl_tp_guard_worker,
|
||||||
sync_all_sl_tp_orders,
|
sync_all_sl_tp_orders,
|
||||||
)
|
)
|
||||||
@@ -158,18 +159,8 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def _sync_trade_monitors_with_ctp(conn, mode: str) -> int:
|
def _sync_trade_monitors_with_ctp(conn, mode: str) -> int:
|
||||||
"""关闭无对应 CTP 持仓的 active 监控(委托被拒或未成交的幽灵记录)。"""
|
"""关闭无对应 CTP 持仓的监控,并撤销残留止盈止损挂单。"""
|
||||||
if not ctp_status(mode).get("connected"):
|
return reconcile_monitors_without_position(conn, mode)
|
||||||
return 0
|
|
||||||
position_keys = _ctp_position_keys(mode)
|
|
||||||
closed = 0
|
|
||||||
for r in conn.execute("SELECT * FROM trade_order_monitors WHERE status='active'").fetchall():
|
|
||||||
mon = dict(r)
|
|
||||||
if _monitor_matches_ctp_position(mon, position_keys):
|
|
||||||
continue
|
|
||||||
conn.execute("UPDATE trade_order_monitors SET status='closed' WHERE id=?", (mon["id"],))
|
|
||||||
closed += 1
|
|
||||||
return closed
|
|
||||||
|
|
||||||
def _effective_active_position_count(conn, mode: str) -> int:
|
def _effective_active_position_count(conn, mode: str) -> int:
|
||||||
if ctp_status(mode).get("connected"):
|
if ctp_status(mode).get("connected"):
|
||||||
|
|||||||
+147
-6
@@ -9,6 +9,8 @@ from typing import Any, Callable, Optional
|
|||||||
from contract_specs import get_contract_spec
|
from contract_specs import get_contract_spec
|
||||||
from ctp_symbol import ths_to_vnpy_symbol
|
from ctp_symbol import ths_to_vnpy_symbol
|
||||||
from vnpy_bridge import (
|
from vnpy_bridge import (
|
||||||
|
ctp_cancel_order,
|
||||||
|
ctp_get_tick_price,
|
||||||
ctp_list_active_orders,
|
ctp_list_active_orders,
|
||||||
ctp_list_positions,
|
ctp_list_positions,
|
||||||
ctp_status,
|
ctp_status,
|
||||||
@@ -60,6 +62,26 @@ def _price_near(a: float, b: float, tick: float) -> bool:
|
|||||||
return abs(float(a) - float(b)) <= max(tick * 0.501, 1e-9)
|
return abs(float(a) - float(b)) <= max(tick * 0.501, 1e-9)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_resting_exit_price(
|
||||||
|
hold_direction: str,
|
||||||
|
kind: str,
|
||||||
|
exit_price: float,
|
||||||
|
mark: Optional[float],
|
||||||
|
tick: float,
|
||||||
|
) -> bool:
|
||||||
|
"""限价平仓单是否会挂在盘口(而非立即成交)。"""
|
||||||
|
if mark is None or mark <= 0:
|
||||||
|
return True
|
||||||
|
buf = max(tick * 0.5, 1e-9)
|
||||||
|
if hold_direction == "long":
|
||||||
|
if kind == "sl":
|
||||||
|
return exit_price < mark - buf
|
||||||
|
return exit_price > mark + buf
|
||||||
|
if kind == "sl":
|
||||||
|
return exit_price > mark + buf
|
||||||
|
return exit_price < mark - buf
|
||||||
|
|
||||||
|
|
||||||
def _find_close_order(
|
def _find_close_order(
|
||||||
active_orders: list[dict],
|
active_orders: list[dict],
|
||||||
*,
|
*,
|
||||||
@@ -114,6 +136,95 @@ def _order_still_active(active_orders: list[dict], vt_order_id: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def cancel_monitor_exit_orders(
|
||||||
|
conn,
|
||||||
|
mon: dict,
|
||||||
|
*,
|
||||||
|
mode: str,
|
||||||
|
) -> int:
|
||||||
|
"""撤销该监控对应的止盈止损平仓挂单。"""
|
||||||
|
ensure_monitor_order_columns(conn)
|
||||||
|
if not ctp_status(mode).get("connected"):
|
||||||
|
return 0
|
||||||
|
sym = (mon.get("symbol") or "").strip()
|
||||||
|
direction = (mon.get("direction") or "long").strip().lower()
|
||||||
|
tick = _tick_size(sym)
|
||||||
|
active = ctp_list_active_orders(mode)
|
||||||
|
cancelled = 0
|
||||||
|
seen: set[str] = set()
|
||||||
|
|
||||||
|
def _try_cancel(vt_id: str) -> None:
|
||||||
|
nonlocal cancelled
|
||||||
|
oid = str(vt_id or "").strip()
|
||||||
|
if not oid or oid in seen:
|
||||||
|
return
|
||||||
|
seen.add(oid)
|
||||||
|
if ctp_cancel_order(mode, oid):
|
||||||
|
cancelled += 1
|
||||||
|
|
||||||
|
for kind, price_key in (("sl", "stop_loss"), ("tp", "take_profit")):
|
||||||
|
raw = mon.get(price_key)
|
||||||
|
try:
|
||||||
|
px = float(raw) if raw is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
px = None
|
||||||
|
stored = str(mon.get(f"{kind}_vt_order_id") or "")
|
||||||
|
if stored:
|
||||||
|
_try_cancel(stored)
|
||||||
|
if px is not None:
|
||||||
|
found = _find_close_order(
|
||||||
|
active, ths_code=sym, hold_direction=direction, price=px, tick=tick,
|
||||||
|
)
|
||||||
|
if found:
|
||||||
|
_try_cancel(str(found.get("order_id") or ""))
|
||||||
|
|
||||||
|
if cancelled:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE trade_order_monitors SET sl_vt_order_id=NULL, tp_vt_order_id=NULL WHERE id=?",
|
||||||
|
(mon["id"],),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cancelled
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile_monitors_without_position(conn, mode: str) -> int:
|
||||||
|
"""持仓已平时:关闭监控并撤销残留止盈止损挂单。"""
|
||||||
|
if not ctp_status(mode).get("connected"):
|
||||||
|
return 0
|
||||||
|
positions = ctp_list_positions(mode)
|
||||||
|
position_keys: set[tuple[str, str]] = set()
|
||||||
|
for p in positions:
|
||||||
|
if int(p.get("lots") or 0) <= 0:
|
||||||
|
continue
|
||||||
|
sym = (p.get("symbol") or "").lower()
|
||||||
|
direction = p.get("direction") or "long"
|
||||||
|
position_keys.add((sym, direction))
|
||||||
|
|
||||||
|
closed = 0
|
||||||
|
for r in conn.execute("SELECT * FROM trade_order_monitors WHERE status='active'").fetchall():
|
||||||
|
mon = dict(r)
|
||||||
|
ms = mon.get("symbol") or ""
|
||||||
|
md = mon.get("direction") or "long"
|
||||||
|
matched = False
|
||||||
|
for ps, pd in position_keys:
|
||||||
|
if pd != md:
|
||||||
|
continue
|
||||||
|
if _match_symbol(ps, ms):
|
||||||
|
matched = True
|
||||||
|
break
|
||||||
|
if matched:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
cancel_monitor_exit_orders(conn, mon, mode=mode)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("cancel exit orders monitor=%s: %s", mon.get("id"), exc)
|
||||||
|
conn.execute("UPDATE trade_order_monitors SET status='closed' WHERE id=?", (mon["id"],))
|
||||||
|
closed += 1
|
||||||
|
if closed:
|
||||||
|
conn.commit()
|
||||||
|
return closed
|
||||||
|
|
||||||
|
|
||||||
def place_monitor_exit_orders(
|
def place_monitor_exit_orders(
|
||||||
conn,
|
conn,
|
||||||
mon: dict,
|
mon: dict,
|
||||||
@@ -142,13 +253,20 @@ def place_monitor_exit_orders(
|
|||||||
positions = ctp_list_positions(mode)
|
positions = ctp_list_positions(mode)
|
||||||
pos = _find_position(positions, sym, direction)
|
pos = _find_position(positions, sym, direction)
|
||||||
if not pos:
|
if not pos:
|
||||||
return {"ok": False, "error": "柜台无对应持仓", "placed": []}
|
reconcile_monitors_without_position(conn, mode)
|
||||||
|
return {"ok": False, "error": "柜台无对应持仓(可能已被止盈/止损平掉)", "placed": []}
|
||||||
|
|
||||||
lots = int(pos.get("lots") or mon.get("lots") or 1)
|
lots = int(pos.get("lots") or 1)
|
||||||
|
if lots != int(mon.get("lots") or 0):
|
||||||
|
conn.execute("UPDATE trade_order_monitors SET lots=? WHERE id=?", (lots, mon["id"]))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
mark = ctp_get_tick_price(mode, sym)
|
||||||
active = ctp_list_active_orders(mode)
|
active = ctp_list_active_orders(mode)
|
||||||
tick = _tick_size(sym)
|
tick = _tick_size(sym)
|
||||||
offset = "close_long" if direction == "long" else "close_short"
|
offset = "close_long" if direction == "long" else "close_short"
|
||||||
placed: list[str] = []
|
placed: list[str] = []
|
||||||
|
skipped: list[str] = []
|
||||||
updates: dict[str, Optional[str]] = {}
|
updates: dict[str, Optional[str]] = {}
|
||||||
|
|
||||||
mid = int(mon.get("id") or 0)
|
mid = int(mon.get("id") or 0)
|
||||||
@@ -166,6 +284,14 @@ def place_monitor_exit_orders(
|
|||||||
return
|
return
|
||||||
if mid > 0 and not force and not _can_place_now(mid, kind):
|
if mid > 0 and not force and not _can_place_now(mid, kind):
|
||||||
return
|
return
|
||||||
|
if not _is_resting_exit_price(direction, kind, price, mark, tick):
|
||||||
|
hint = f"{'止损' if kind == 'sl' else '止盈'} {price}"
|
||||||
|
if mark:
|
||||||
|
hint += f"(现价 {mark} 会立即成交)"
|
||||||
|
skipped.append(hint)
|
||||||
|
if not force:
|
||||||
|
logger.info("SL/TP skip immediate fill monitor=%s %s mark=%s", mid, kind, mark)
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
_mark_place_attempt(mid, kind)
|
_mark_place_attempt(mid, kind)
|
||||||
result = execute_order(
|
result = execute_order(
|
||||||
@@ -185,11 +311,18 @@ def place_monitor_exit_orders(
|
|||||||
if oid:
|
if oid:
|
||||||
updates[f"{kind}_vt_order_id"] = oid
|
updates[f"{kind}_vt_order_id"] = oid
|
||||||
placed.append(f"{kind}@{price}")
|
placed.append(f"{kind}@{price}")
|
||||||
|
time.sleep(0.3)
|
||||||
|
positions_after = ctp_list_positions(mode)
|
||||||
|
if not _find_position(positions_after, sym, direction):
|
||||||
|
cancel_monitor_exit_orders(conn, mon, mode=mode)
|
||||||
|
reconcile_monitors_without_position(conn, mode)
|
||||||
|
return
|
||||||
|
|
||||||
sl_id = str(mon.get("sl_vt_order_id") or "")
|
sl_id = str(mon.get("sl_vt_order_id") or "")
|
||||||
tp_id = str(mon.get("tp_vt_order_id") or "")
|
tp_id = str(mon.get("tp_vt_order_id") or "")
|
||||||
_maybe_place("sl", sl_f, sl_id)
|
_maybe_place("sl", sl_f, sl_id)
|
||||||
_maybe_place("tp", tp_f, tp_id)
|
if _find_position(ctp_list_positions(mode), sym, direction):
|
||||||
|
_maybe_place("tp", tp_f, tp_id)
|
||||||
|
|
||||||
if updates:
|
if updates:
|
||||||
sl_new = updates.get("sl_vt_order_id", mon.get("sl_vt_order_id"))
|
sl_new = updates.get("sl_vt_order_id", mon.get("sl_vt_order_id"))
|
||||||
@@ -200,10 +333,16 @@ def place_monitor_exit_orders(
|
|||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
if not placed and not updates:
|
if not placed and not updates and not skipped:
|
||||||
return {"ok": True, "message": "无需新委托", "placed": []}
|
return {"ok": True, "message": "无需新委托", "placed": []}
|
||||||
msg = "已提交: " + ", ".join(placed) if placed else "委托已在柜台"
|
msg_parts = []
|
||||||
return {"ok": True, "message": msg, "placed": placed}
|
if placed:
|
||||||
|
msg_parts.append("已提交: " + ", ".join(placed))
|
||||||
|
elif updates:
|
||||||
|
msg_parts.append("委托已在柜台")
|
||||||
|
if skipped:
|
||||||
|
msg_parts.append("未挂单: " + "; ".join(skipped))
|
||||||
|
return {"ok": True, "message": ";".join(msg_parts), "placed": placed, "skipped": skipped}
|
||||||
|
|
||||||
|
|
||||||
def monitor_order_status(
|
def monitor_order_status(
|
||||||
@@ -256,6 +395,7 @@ def sync_all_sl_tp_orders(conn, mode: str) -> int:
|
|||||||
ensure_monitor_order_columns(conn)
|
ensure_monitor_order_columns(conn)
|
||||||
if not ctp_status(mode).get("connected"):
|
if not ctp_status(mode).get("connected"):
|
||||||
return 0
|
return 0
|
||||||
|
reconcile_monitors_without_position(conn, mode)
|
||||||
placed_n = 0
|
placed_n = 0
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT * FROM trade_order_monitors WHERE status='active'"
|
"SELECT * FROM trade_order_monitors WHERE status='active'"
|
||||||
@@ -296,6 +436,7 @@ def start_sl_tp_guard_worker(
|
|||||||
try:
|
try:
|
||||||
if init_tables_fn:
|
if init_tables_fn:
|
||||||
init_tables_fn(conn)
|
init_tables_fn(conn)
|
||||||
|
reconcile_monitors_without_position(conn, mode)
|
||||||
n = sync_all_sl_tp_orders(conn, mode)
|
n = sync_all_sl_tp_orders(conn, mode)
|
||||||
if n:
|
if n:
|
||||||
logger.info("止盈止损守护: 新挂 %d 笔委托", n)
|
logger.info("止盈止损守护: 新挂 %d 笔委托", n)
|
||||||
|
|||||||
+3
-1
@@ -442,7 +442,9 @@
|
|||||||
.then(function (r) { return r.json(); })
|
.then(function (r) { return r.json(); })
|
||||||
.then(function (d) {
|
.then(function (d) {
|
||||||
if (!d.ok) throw new Error(d.error || d.message || '委托失败');
|
if (!d.ok) throw new Error(d.error || d.message || '委托失败');
|
||||||
alert(d.message || '委托已提交');
|
var msg = d.message || '委托已提交';
|
||||||
|
if (d.skipped && d.skipped.length) msg += '\n' + d.skipped.join('\n');
|
||||||
|
alert(msg);
|
||||||
pollPositions();
|
pollPositions();
|
||||||
})
|
})
|
||||||
.catch(function (e) {
|
.catch(function (e) {
|
||||||
|
|||||||
@@ -831,6 +831,21 @@ class CtpBridge:
|
|||||||
raise RuntimeError("CTP 拒单或未返回委托号(请检查合约代码、价格是否为最小变动价位整数倍)")
|
raise RuntimeError("CTP 拒单或未返回委托号(请检查合约代码、价格是否为最小变动价位整数倍)")
|
||||||
return str(vt_orderid)
|
return str(vt_orderid)
|
||||||
|
|
||||||
|
def cancel_order(self, vt_orderid: str) -> bool:
|
||||||
|
if not self._engine or not vt_orderid:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
order = self._engine.get_order(vt_orderid)
|
||||||
|
if order is None:
|
||||||
|
return False
|
||||||
|
req = order.create_cancel_request()
|
||||||
|
self._engine.cancel_order(req, GATEWAY_NAME)
|
||||||
|
logger.info("CTP 撤单 %s", vt_orderid)
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("CTP 撤单失败 %s: %s", vt_orderid, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def get_bridge() -> CtpBridge:
|
def get_bridge() -> CtpBridge:
|
||||||
global _bridge
|
global _bridge
|
||||||
@@ -904,6 +919,12 @@ def ctp_list_active_orders(mode: str) -> list[dict[str, Any]]:
|
|||||||
return b.list_active_orders()
|
return b.list_active_orders()
|
||||||
|
|
||||||
|
|
||||||
|
def ctp_cancel_order(mode: str, vt_orderid: str) -> bool:
|
||||||
|
b = get_bridge()
|
||||||
|
b.ensure_connected(mode)
|
||||||
|
return b.cancel_order(vt_orderid)
|
||||||
|
|
||||||
|
|
||||||
def ctp_get_tick_price(mode: str, ths_code: str) -> Optional[float]:
|
def ctp_get_tick_price(mode: str, ths_code: str) -> Optional[float]:
|
||||||
"""CTP 柜台最新价(需已连接并订阅)。"""
|
"""CTP 柜台最新价(需已连接并订阅)。"""
|
||||||
b = get_bridge()
|
b = get_bridge()
|
||||||
|
|||||||
Reference in New Issue
Block a user