From 3aa3e1ad30428e86d79d78613b10fccf2ba4ebfb Mon Sep 17 00:00:00 2001 From: dekun Date: Wed, 24 Jun 2026 13:53:10 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20CTP=E6=8A=A5=E5=8D=95=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=E4=B8=8Etick=E4=BB=B7=EF=BC=8C=E5=B8=82?= =?UTF-8?q?=E4=BB=B7=E6=94=B9=E9=99=90=E4=BB=B7=EF=BC=8C=E9=83=91=E5=95=86?= =?UTF-8?q?=E6=89=80=E5=B9=B3=E4=BB=8A=E5=B9=B3=E6=98=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- install_trading.py | 11 ++++- static/js/trade.js | 3 +- vnpy_bridge.py | 119 ++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 123 insertions(+), 10 deletions(-) diff --git a/install_trading.py b/install_trading.py index 218869e..932df70 100644 --- a/install_trading.py +++ b/install_trading.py @@ -53,6 +53,7 @@ from vnpy_bridge import ( ctp_list_positions, ctp_status, execute_order, + get_bridge, ) @@ -527,6 +528,12 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se conn.close() return jsonify({"ok": False, "error": err}), 403 mode = get_trading_mode(get_setting) + ctp_st = ctp_status(mode) + if not ctp_st.get("connected"): + conn.close() + if get_bridge().connect_in_progress(): + return jsonify({"ok": False, "error": "CTP 连接中,请稍候再下单"}), 400 + return jsonify({"ok": False, "error": "请先连接 CTP"}), 400 sizing = get_sizing_mode(get_setting) if offset.startswith("open") and sizing == MODE_RISK: sl = float(d.get("stop_loss") or 0) @@ -575,8 +582,8 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se conn.commit() send_wechat_msg(f"{trading_mode_label(get_setting)} {offset} {sym} {direction} {lots}手 @{price}") conn.close() - return jsonify({"ok": True, "result": result, "lots": lots}) - except ValueError as exc: + return jsonify({"ok": True, "result": result, "lots": lots, "message": "委托已提交柜台,限价单需成交后才会显示持仓"}) + except (ValueError, RuntimeError) as exc: conn.close() return jsonify({"ok": False, "error": str(exc)}), 400 except Exception as exc: diff --git a/static/js/trade.js b/static/js/trade.js index baac372..d12ea7a 100644 --- a/static/js/trade.js +++ b/static/js/trade.js @@ -299,7 +299,8 @@ showOrderMsg(data.error || '下单失败', false); return; } - showOrderMsg('开仓成功 · ' + (data.lots || lots) + ' 手', true); + var msg = data.message || ('开仓成功 · ' + (data.lots || lots) + ' 手'); + showOrderMsg(msg, true); pollPositions(); refreshQuote(); setTimeout(function () { showOrderMsg(''); }, 4000); diff --git a/vnpy_bridge.py b/vnpy_bridge.py index af9b694..5ecd850 100644 --- a/vnpy_bridge.py +++ b/vnpy_bridge.py @@ -13,6 +13,7 @@ from locale_fix import ensure_process_locale ensure_process_locale() from ctp_symbol import ths_to_vnpy_symbol, to_vnpy_exchange +from contract_specs import get_contract_spec logger = logging.getLogger(__name__) @@ -80,6 +81,18 @@ def _format_ctp_failure(ctp_logs: list[str]) -> str: return "CTP 连接超时:未收到柜台回报。请检查 SimNow 账号、前置地址、网络(nc 测端口),并用快期验证账号" +def round_to_tick(price: float, tick: float) -> float: + if tick <= 0: + return float(price) + steps = round(float(price) / tick) + return round(steps * tick, 10) + + +def _is_long_direction(direction_obj: Any) -> bool: + s = str(direction_obj or "") + return "LONG" in s.upper() or "多" in s + + class CtpBridge: def __init__(self) -> None: self._engine = None @@ -242,6 +255,84 @@ class CtpBridge: return self.connect(mode) + def require_connected(self, mode: str) -> None: + """报单前检查:须已连接,不在此发起阻塞式 connect。""" + if self._connect_in_progress: + raise RuntimeError("CTP 连接中,请稍候再下单") + if self._connected_mode != mode or not self.ping(): + raise RuntimeError("请先连接 CTP(持仓监控页点击「连接 CTP」)") + if not self._td_logged_in(): + raise RuntimeError("CTP 交易通道未登录,请重连 CTP 后再下单") + + def _td_logged_in(self) -> bool: + try: + gw = self._engine.get_gateway(GATEWAY_NAME) + td = gw.td_api + return bool(getattr(td, "login_status", False)) + except Exception: + return False + + def _find_position(self, sym: str, ex_name: str, hold_direction: str) -> Any: + if not self._engine: + return None + sym_l = sym.lower() + ex_u = ex_name.upper() + want_long = hold_direction == "long" + try: + for pos in self._engine.get_all_positions(): + ps = (getattr(pos, "symbol", "") or "").lower() + pe = getattr(pos, "exchange", None) + pe_s = str(pe.value if hasattr(pe, "value") else pe or "").upper() + if ps != sym_l or pe_s != ex_u: + continue + vol = int(getattr(pos, "volume", 0) or 0) + if vol <= 0: + continue + is_long = _is_long_direction(getattr(pos, "direction", None)) + if is_long == want_long: + return pos + except Exception as exc: + logger.debug("find position: %s", exc) + return None + + def _resolve_close_offset(self, sym: str, ex_name: str, hold_direction: str, lots: int) -> Any: + from vnpy.trader.constant import Offset + + if ex_name not in ("CZCE", "CFFEX"): + return Offset.CLOSE + pos = self._find_position(sym, ex_name, hold_direction) + if not pos: + return Offset.CLOSE + vol = int(getattr(pos, "volume", 0) or 0) + yd = int(getattr(pos, "yd_volume", 0) or 0) + today = max(0, vol - yd) + if today >= lots: + return Offset.CLOSETODAY + return Offset.CLOSEYESTERDAY + + def _aggressive_limit_price( + self, + ths_code: str, + sym: str, + ex_name: str, + direction: Any, + tick: float, + fallback: float, + ) -> float: + from vnpy.trader.constant import Direction + + self.subscribe_symbol(ths_code) + lp = fallback + detail = self.get_tick_detail(ths_code, mode=self._connected_mode or "") + if detail.get("price"): + lp = float(detail["price"]) + slip = max(tick, tick * 3) + if direction == Direction.LONG: + lp = lp + slip + else: + lp = max(tick, lp - slip) + return round_to_tick(lp, tick) + def ping(self) -> bool: """检测连接是否仍有效;无效则清除 connected 状态。""" if not self._engine or not self._connected_mode: @@ -684,10 +775,13 @@ class CtpBridge: if not self._engine: raise RuntimeError("CTP 未初始化") + if not self._td_logged_in(): + raise RuntimeError("CTP 交易通道未登录,请重连后再下单") + sym, ex_name = ths_to_vnpy_symbol(ths_code) exchange = to_vnpy_exchange(ex_name) lots = max(1, int(lots)) - price = float(price) + tick = float(get_contract_spec(ths_code).get("tick_size") or 1.0) offset = (offset or "open").lower() direction = (direction or "long").lower() @@ -696,16 +790,23 @@ class CtpBridge: d = Direction.LONG if direction == "long" or offset == "open_long" else Direction.SHORT off = Offset.OPEN elif offset in ("close", "close_long", "close_short"): - # 平多 = 卖;平空 = 买 - if direction == "long" or offset == "close_long": + hold = "long" if direction == "long" or offset == "close_long" else "short" + if hold == "long": d = Direction.SHORT else: d = Direction.LONG - off = Offset.CLOSE + off = self._resolve_close_offset(sym, ex_name, hold, lots) else: raise ValueError(f"未知开平: {offset}") - ot = OrderType.MARKET if (order_type or "limit").lower() == "market" else OrderType.LIMIT + use_market = (order_type or "limit").lower() == "market" + ot = OrderType.LIMIT + if use_market: + price = self._aggressive_limit_price(ths_code, sym, ex_name, d, tick, price) + else: + price = round_to_tick(float(price), tick) + if price <= 0: + raise ValueError("委托价格无效,请检查行情或手动填写价格") req = OrderRequest( symbol=sym, @@ -716,9 +817,13 @@ class CtpBridge: price=price, offset=off, ) + logger.info( + "CTP 报单 %s %s %s %s手 @%s offset=%s type=%s", + sym, ex_name, d, lots, price, off, ot, + ) vt_orderid = self._engine.send_order(req, GATEWAY_NAME) if not vt_orderid: - raise RuntimeError("CTP 拒单或未返回委托号") + raise RuntimeError("CTP 拒单或未返回委托号(请检查合约代码、价格是否为最小变动价位整数倍)") return str(vt_orderid) @@ -849,7 +954,7 @@ def execute_order( f"模拟盘需配置 .env 中 SIMNOW_USER / SIMNOW_PASSWORD 等" ) b = get_bridge() - b.ensure_connected(mode) + b.require_connected(mode) order_id = b.send_order( ths_code=symbol, offset=offset,