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:
+219
-20
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user