Track open orders as pending until CTP fill, with cancel and timeout.

Add configurable pending timeout in settings and clearer CTP password save feedback.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-26 00:05:45 +08:00
parent 7ea8fb6301
commit a23f2c80ca
10 changed files with 567 additions and 41 deletions
+219 -20
View File
@@ -32,6 +32,11 @@ from position_stream import position_hub, start_position_worker
from ctp_reconnect import start_ctp_reconnect_worker
from ctp_premarket_connect import start_ctp_premarket_connect_worker
from ctp_fee_worker import start_ctp_fee_worker
from order_pending import (
cancel_pending_monitor,
pending_auto_cancel_remaining,
reconcile_pending_orders,
)
from db_conn import execute_retry
from sl_tp_guard import (
cancel_monitor_exit_orders,
@@ -65,6 +70,8 @@ from trading_context import (
get_fixed_amount,
get_fixed_lots,
get_max_margin_pct,
get_pending_order_timeout_min,
get_pending_order_timeout_sec,
get_risk_percent,
get_sizing_mode,
get_trailing_be_tick_buffer,
@@ -425,15 +432,33 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
ctp_open_time: Optional[str] = None,
open_time: Optional[str] = None,
monitor_type: str = "manual",
status: str = "active",
vt_order_id: Optional[str] = None,
order_price: Optional[float] = None,
) -> int:
ensure_monitor_order_columns(conn)
codes = ths_to_codes(sym) or {}
sl_f = float(sl) if sl not in (None, "") else None
tp_f = float(tp) if tp not in (None, "") else None
now_s = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
status_val = status if status in ("pending", "active") else "active"
order_px = float(order_price if order_price is not None else price)
existing = _find_active_monitor(conn, sym, direction)
if not existing:
for r in conn.execute(
"SELECT * FROM trade_order_monitors WHERE status='pending' ORDER BY id DESC"
).fetchall():
row = dict(r)
if (row.get("direction") or "long") != (direction or "long").strip().lower():
continue
if _match_ctp_symbol(sym, row.get("symbol") or ""):
existing = row
break
if existing:
mid = int(existing["id"])
existing_status = (existing.get("status") or "active").strip().lower()
if existing_status == "active" and status_val == "pending":
status_val = "active"
initial_sl = existing.get("initial_stop_loss")
if sl_f is None:
sl_f = float(existing["stop_loss"]) if existing.get("stop_loss") is not None else None
@@ -448,11 +473,12 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
open_time_val = open_time
elif monitor_type == "ctp_sync" and ctp_open_time:
open_time_val = ctp_open_time
vt_val = vt_order_id or existing.get("vt_order_id")
conn.execute(
"""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=?
monitor_type=?, status=?, vt_order_id=?, order_price=?
WHERE id=?""",
(
sym,
@@ -466,6 +492,9 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
trailing_be,
open_time_val,
monitor_type if monitor_type != "manual" else (existing.get("monitor_type") or "manual"),
status_val,
vt_val,
order_px,
mid,
),
)
@@ -480,8 +509,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
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?, 'active')""",
open_time, monitor_type, status, vt_order_id, order_price
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
sym,
codes.get("name", sym),
@@ -495,10 +524,14 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
trailing_be,
open_time_val,
monitor_type,
status_val,
vt_order_id,
order_px,
),
)
mid = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0])
_close_duplicate_monitors(conn, sym, direction, mid)
if status_val == "active":
_close_duplicate_monitors(conn, sym, direction, mid)
return mid
def _sync_monitor_from_ctp(
@@ -748,6 +781,84 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
"trailing_r_locked": int(mon.get("trailing_r_locked") or 0) if mon else 0,
}
def _compose_pending_row(
mon: dict,
*,
mode: str,
capital: float,
now_iso: str,
) -> Optional[dict]:
sym = (mon.get("symbol") or "").strip()
direction = (mon.get("direction") or "long").strip().lower()
lots = int(mon.get("lots") or 0)
if not sym or lots <= 0:
return None
order_price = float(mon.get("order_price") or mon.get("entry_price") or 0)
codes = ths_to_codes(sym)
sl = float(mon["stop_loss"]) if mon.get("stop_loss") is not None else None
tp = float(mon["take_profit"]) if mon.get("take_profit") is not None else None
pos_metrics = calc_position_metrics(
direction, order_price, sl or order_price, tp or order_price, lots, order_price, capital, sym,
)
open_time = (mon.get("open_time") or "").strip()
timeout_sec = get_pending_order_timeout_sec(get_setting)
remain = pending_auto_cancel_remaining(mon, timeout_sec=timeout_sec)
return {
"key": f"{_canonical_position_key(sym, direction)}:pending:{mon.get('id')}",
"order_state": "pending",
"source": "pending",
"source_label": "委托挂单中",
"sync_pending": True,
"monitor_id": mon.get("id"),
"symbol": codes.get("name", sym) if codes else (mon.get("symbol_name") or sym),
"symbol_code": sym,
"direction": direction,
"direction_label": "做多" if direction == "long" else "做空",
"lots": lots,
"entry_price": order_price,
"order_price": order_price,
"stop_loss": sl,
"take_profit": tp,
"open_time": open_time or None,
"holding_duration": _holding_duration(open_time, now_iso) if open_time else None,
"mark_price": order_price,
"current_price": order_price,
"margin": pos_metrics.get("margin"),
"margin_source": "estimate",
"position_pct": pos_metrics.get("position_pct"),
"risk_amount": pos_metrics.get("risk_amount") if sl is not None else None,
"reward_amount": pos_metrics.get("reward_amount") if tp is not None else None,
"rr_ratio": pos_metrics.get("rr_ratio") if sl is not None and tp is not None else None,
"float_pnl": None,
"est_fee": None,
"can_close": False,
"close_allowed": False,
"can_cancel_order": True,
"auto_cancel_sec": remain,
"pending_timeout_sec": timeout_sec,
"pending_timeout_min": max(1, timeout_sec // 60),
"vt_order_id": mon.get("vt_order_id"),
"sl_order_active": False,
"tp_order_active": False,
"sl_monitoring": bool(sl is not None),
"tp_monitoring": bool(tp is not None),
"can_place_orders": False,
"pending_orders": [],
"trailing_be": bool(mon.get("trailing_be")),
"trailing_r_locked": int(mon.get("trailing_r_locked") or 0),
}
def _reconcile_pending(conn, mode: str, *, capital: float = 0.0) -> None:
reconcile_pending_orders(
conn,
mode,
match_symbol_fn=_match_ctp_symbol,
sync_monitor_fn=_sync_monitor_from_ctp,
capital=capital,
list_positions_fn=_ctp_positions,
timeout_sec=get_pending_order_timeout_sec(get_setting),
)
def _build_trading_live_rows(conn, *, fast: bool = False) -> list[dict]:
from zoneinfo import ZoneInfo
tz = ZoneInfo("Asia/Shanghai")
@@ -851,16 +962,33 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
continue
seen.add(rk)
deduped.append(row)
pending_raw = [
dict(r) for r in conn.execute(
"SELECT * FROM trade_order_monitors WHERE status='pending' ORDER BY id DESC"
).fetchall()
]
for mon in pending_raw:
try:
prow = _compose_pending_row(
mon, mode=mode, capital=capital, now_iso=now_iso,
)
if prow:
deduped.insert(0, prow)
except Exception as exc:
logger.warning("compose pending row failed: %s", exc)
return deduped
def _build_trading_live_payload(conn, *, fast: bool = False) -> dict:
mode = get_trading_mode(get_setting)
ctp_st = ctp_status(mode)
capital = _capital(conn)
if not fast and ctp_st.get("connected"):
_reconcile_pending(conn, mode, capital=capital)
if not fast:
_ensure_monitors_from_ctp(conn, mode)
rows = _build_trading_live_rows(conn, fast=fast)
pending_orders = _build_pending_orders(conn, mode)
capital = _capital(conn)
risk = get_risk_status(conn, active_count=_effective_active_position_count(conn, mode))
return {
"ok": True,
@@ -871,6 +999,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
"trading_mode_label": trading_mode_label(get_setting),
"risk_status": risk,
"trading_session": is_trading_session(),
"pending_order_timeout_min": get_pending_order_timeout_min(get_setting),
}
def _refresh_trading_live_snapshot(*, fast: bool = False) -> dict:
@@ -969,6 +1098,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
fixed_amount=get_fixed_amount(get_setting),
risk_percent=get_risk_percent(get_setting),
max_margin_pct=get_max_margin_pct(get_setting),
pending_order_timeout_min=get_pending_order_timeout_min(get_setting),
recommend_rows=rec_cache.get("rows") or [],
recommend_updated_at=rec_cache.get("updated_at"),
)
@@ -1146,21 +1276,57 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
conn = get_db()
try:
init_strategy_tables(conn)
mode = get_trading_mode(get_setting)
row = conn.execute(
"SELECT id FROM trade_order_monitors WHERE id=? AND status='active'",
"SELECT * FROM trade_order_monitors WHERE id=? AND status IN ('active', 'pending')",
(monitor_id,),
).fetchone()
if not row:
return jsonify({"ok": False, "error": "记录不存在或已关闭"}), 404
mon = dict(row)
if (mon.get("status") or "").strip().lower() == "pending":
ok, msg = cancel_pending_monitor(conn, mon, mode)
_push_position_snapshot_async(fast=False)
return jsonify({"ok": ok, "message": msg})
conn.execute(
"UPDATE trade_order_monitors SET status='closed' WHERE id=?",
(monitor_id,),
)
conn.commit()
_push_position_snapshot_async(fast=False)
return jsonify({"ok": True, "message": "已取消本地止盈止损监控"})
finally:
conn.close()
@app.route("/api/trading/monitor/cancel-open", methods=["POST"])
@login_required
def api_trading_monitor_cancel_open():
"""撤销 pending 开仓委托(柜台撤单 + 关闭本地记录)。"""
d = request.get_json(silent=True) or {}
try:
monitor_id = int(d.get("monitor_id") or 0)
except (TypeError, ValueError):
monitor_id = 0
if monitor_id <= 0:
return jsonify({"ok": False, "error": "无效的委托记录"}), 400
conn = get_db()
try:
init_strategy_tables(conn)
mode = get_trading_mode(get_setting)
if not ctp_status(mode).get("connected"):
return jsonify({"ok": False, "error": "请先连接 CTP"}), 400
row = conn.execute(
"SELECT * FROM trade_order_monitors WHERE id=? AND status='pending'",
(monitor_id,),
).fetchone()
if not row:
return jsonify({"ok": False, "error": "未找到挂单中的开仓委托"}), 404
ok, msg = cancel_pending_monitor(conn, dict(row), mode)
_push_position_snapshot_async(fast=False)
return jsonify({"ok": ok, "message": msg})
finally:
conn.close()
@app.route("/api/trading/close", methods=["POST"])
@login_required
def api_trading_close():
@@ -1469,6 +1635,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
tp = d.get("take_profit")
trailing_be = 1 if d.get("trailing_be") else 0
open_ts = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
vt_order_id = str(result.get("order_id") or "")
mid = _upsert_open_monitor(
conn,
sym=sym,
@@ -1480,28 +1647,60 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
trailing_be=trailing_be,
open_time=open_ts,
monitor_type="manual",
status="pending",
vt_order_id=vt_order_id or None,
order_price=price,
)
conn.commit()
_push_position_snapshot_async(fast=True)
import time
time.sleep(1.5)
_sync_monitor_from_ctp(
conn, mid, sym, direction, mode, capital=_capital(conn),
)
mon_row = conn.execute(
"SELECT * FROM trade_order_monitors WHERE id=?", (mid,),
_reconcile_pending(conn, mode, capital=_capital(conn))
st_row = conn.execute(
"SELECT status FROM trade_order_monitors WHERE id=?", (mid,),
).fetchone()
if mon_row and (sl or tp):
filled = st_row and (st_row["status"] or "").strip().lower() == "active"
if not filled:
try:
ensure_monitor_order_columns(conn)
cancel_monitor_exit_orders(conn, dict(mon_row), mode=mode)
except Exception as exc:
logger.warning("清理旧版止盈止损挂单失败: %s", exc)
get_bridge().refresh_positions()
except Exception:
pass
_reconcile_pending(conn, mode, capital=_capital(conn))
st_row = conn.execute(
"SELECT status FROM trade_order_monitors WHERE id=?", (mid,),
).fetchone()
filled = st_row and (st_row["status"] or "").strip().lower() == "active"
if filled:
_sync_monitor_from_ctp(
conn, mid, sym, direction, mode, capital=_capital(conn),
)
mon_row = conn.execute(
"SELECT * FROM trade_order_monitors WHERE id=?", (mid,),
).fetchone()
if mon_row and (sl or tp):
try:
ensure_monitor_order_columns(conn)
cancel_monitor_exit_orders(conn, dict(mon_row), mode=mode)
except Exception as exc:
logger.warning("清理旧版止盈止损挂单失败: %s", exc)
conn.commit()
_push_position_snapshot_async(fast=False)
msg = (
f"开仓成功 · {lots}"
if filled
else (
f"委托已提交 · {lots} 手挂单中"
f"{get_pending_order_timeout_sec(get_setting) // 60} 分钟未成交自动撤单)"
)
)
conn.commit()
send_wechat_msg(f"{trading_mode_label(get_setting)} {offset} {sym} {direction} {lots}手 @{price}")
conn.close()
_push_position_snapshot_async()
return jsonify({"ok": True, "result": result, "lots": lots, "message": "委托已提交柜台,限价单需成交后才会显示持仓"})
return jsonify({
"ok": True,
"result": result,
"lots": lots,
"message": msg if offset.startswith("open") else "委托已提交柜台",
"filled": filled if offset.startswith("open") else None,
})
except (ValueError, RuntimeError) as exc:
conn.close()
return jsonify({"ok": False, "error": str(exc)}), 400