From 7a4a3f08e5652713479487c7a992fd6e4fbd5dbb Mon Sep 17 00:00:00 2001 From: dekun Date: Fri, 26 Jun 2026 11:05:13 +0800 Subject: [PATCH] Fix slow CTP position sync after restart and link positions to 15m K-line. Co-authored-by: Cursor --- install_trading.py | 25 +++++++++++++++++++++---- static/css/trade.css | 3 +++ static/js/trade.js | 25 ++++++++++++++++++++----- vnpy_bridge.py | 37 ++++++++++++++++++++++++++++++++++++- 4 files changed, 80 insertions(+), 10 deletions(-) diff --git a/install_trading.py b/install_trading.py index a7ed4fe..bf56ca2 100644 --- a/install_trading.py +++ b/install_trading.py @@ -771,11 +771,17 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se "monitor_id": mon["id"] if mon else None, }) row_key = _canonical_position_key(sym, direction) + ctp_st = ctp_status(mode) + sync_pending = ( + mon is not None + and ctp is None + and bool(ctp_st.get("connected")) + ) return { "key": row_key, "source": "ctp" if ctp else "local", "source_label": source_label, - "sync_pending": ctp is None and mon is not None, + "sync_pending": sync_pending, "monitor_id": mon["id"] if mon else None, "symbol_code": sym, **_symbol_display_fields(sym), @@ -1024,7 +1030,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se capital = _capital(conn) if not fast and ctp_st.get("connected"): _reconcile_pending(conn, mode, capital=capital) - if not fast: + if ctp_st.get("connected"): _ensure_monitors_from_ctp(conn, mode) rows = _build_trading_live_rows(conn, fast=fast) pending_orders = _build_pending_orders(conn, mode) @@ -2262,8 +2268,19 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se def _position_worker_refresh() -> dict: _pos_refresh_tick["n"] += 1 - # 每秒轻量刷新;每 5 秒做一次 CTP 持仓/挂单对账,避免频繁 query 导致 vnctptd 崩溃 - return _refresh_trading_live_snapshot(fast=(_pos_refresh_tick["n"] % 5 != 0)) + mode = get_trading_mode(get_setting) + connected = bool(ctp_status(mode).get("connected")) + # 已连接时每 2 秒完整对账;未连接时每 5 秒轻量刷新(禁止 query_position) + if connected: + use_fast = _pos_refresh_tick["n"] % 2 != 0 + else: + use_fast = _pos_refresh_tick["n"] % 5 != 0 + payload = _refresh_trading_live_snapshot(fast=use_fast) + if connected and use_fast and any( + r.get("sync_pending") for r in (payload.get("rows") or []) + ): + payload = _refresh_trading_live_snapshot(fast=False) + return payload start_position_worker( refresh_fn=_position_worker_refresh, diff --git a/static/css/trade.css b/static/css/trade.css index 40300a6..c0c7067 100644 --- a/static/css/trade.css +++ b/static/css/trade.css @@ -65,6 +65,9 @@ .gap-badge{font-size:.72rem} .rec-market-link{color:inherit;text-decoration:none;display:inline-flex;flex-wrap:wrap;align-items:baseline;gap:.2rem .35rem} .rec-market-link:hover strong,.rec-market-link:hover .text-accent{color:var(--accent);text-decoration:underline} +.pos-market-link{color:inherit;text-decoration:none} +.pos-market-link:hover{color:var(--accent)} +.pos-market-link:hover .text-accent{text-decoration:underline} .pos-symbol-sub{font-size:.72rem;line-height:1.35} .pos-main-badge{font-size:.68rem;vertical-align:middle} .pos-change-up{color:var(--profit)} diff --git a/static/js/trade.js b/static/js/trade.js index 71ea5e2..d92b7e0 100644 --- a/static/js/trade.js +++ b/static/js/trade.js @@ -30,6 +30,7 @@ var isTradingSession = false; var hasSlTpMonitoring = false; var ctpConnected = false; + var ctpConnecting = false; var positionsRendered = false; var selectedMaxLots = null; var recommendMaxByProduct = {}; @@ -164,6 +165,7 @@ var cooldownSec = (data.ctp_status && data.ctp_status.login_cooldown_sec) || 0; if (cooldownSec > 0) connecting = false; ctpConnected = !!connected; + ctpConnecting = !!connecting; isTradingSession = !!data.trading_session; syncCtpBadgeFromStatus(data.ctp_status || { connected: connected, connecting: connecting }); if (!connected && !connecting && data.ctp_status && data.ctp_status.last_error) { @@ -811,13 +813,17 @@ var name = row.symbol_name || row.symbol || ''; var code = row.symbol_code || ''; var mainBadge = row.symbol_is_main ? ' 主力' : ''; - var title = name + mainBadge; + var inner = name + mainBadge; if (code && String(name).toLowerCase() !== String(code).toLowerCase()) { - title += ' ' + code + ''; + inner += ' ' + code + ''; } else if (!name && code) { - title = '' + code + ''; + inner = '' + code + ''; } - return title + extraBadges; + if (marketNavEnabled && code) { + var href = '/market?symbol=' + encodeURIComponent(code) + '&period=15m'; + inner = '' + inner + ''; + } + return inner + extraBadges; } function posSymbolSubHtml(row) { @@ -910,7 +916,16 @@ ' · ' + slTpStatusHtml(row) + ' · 移动保本 ' + trailingStatusHtml(row) + (slTpBtn ? ' · ' + slTpBtn : '') + - (row.sync_pending ? ' · 同步柜台中…' : ''); + (function () { + if (row.order_state === 'pending' || !row.monitor_id) return ''; + if (ctpConnecting) { + return ' · CTP 连接中…'; + } + if (row.sync_pending) { + return ' · 同步柜台中…'; + } + return ''; + }()); var feeLabel = row.fee_source === 'ctp' ? '手续费(柜台)' : '手续费'; var marginLabel = row.margin_source === 'ctp' ? '占用保证金(柜台)' : '占用保证金'; var openLabel = '开仓'; diff --git a/vnpy_bridge.py b/vnpy_bridge.py index 8e5a179..0733e20 100644 --- a/vnpy_bridge.py +++ b/vnpy_bridge.py @@ -73,6 +73,8 @@ def _load_persisted_last_error() -> str: return (get_setting(CTP_LAST_ERROR_KEY, "") or "").strip() _position_refresh_callback: Optional[Callable[[], None]] = None +_position_refresh_debounce_lock = threading.Lock() +_position_refresh_debounce_ts: float = 0.0 def set_position_refresh_callback(fn: Optional[Callable[[], None]]) -> None: @@ -89,6 +91,23 @@ def _fire_position_refresh_callback() -> None: except Exception as exc: logger.debug("position refresh callback: %s", exc) + +def _fire_position_refresh_callback_debounced(*, min_interval: float = 0.35) -> None: + global _position_refresh_debounce_ts + now = time.monotonic() + with _position_refresh_debounce_lock: + if now - _position_refresh_debounce_ts < min_interval: + return + _position_refresh_debounce_ts = now + _fire_position_refresh_callback() + + +def _fire_position_refresh_burst() -> None: + """连接后持仓回报可能分批到达,分多次触发快照刷新。""" + _fire_position_refresh_callback() + for delay in (1.5, 4.0, 10.0, 25.0): + threading.Timer(delay, _fire_position_refresh_callback).start() + _bridge: Optional["CtpBridge"] = None _bridge_lock = threading.Lock() _ctp_td_lock = threading.RLock() @@ -203,6 +222,7 @@ class CtpBridge: self._last_trade_query_ts: float = 0.0 self._last_connect_ok_ts: float = 0.0 self._tick_hooked = False + self._position_hooked = False self._bar_generators: dict[str, Any] = {} self._bars_1m: dict[str, deque] = {} self._init_engine() @@ -217,11 +237,26 @@ class CtpBridge: self._ee = EventEngine() self._engine = MainEngine(self._ee) self._engine.add_gateway(CtpGateway) + self._ensure_position_event_hook() except ImportError: self._last_error = "未安装 vnpy / vnpy_ctp,请 pip install vnpy vnpy_ctp" except Exception as exc: self._last_error = str(exc) + def _ensure_position_event_hook(self) -> None: + if self._position_hooked or not self._ee: + return + try: + from vnpy.trader.event import EVENT_POSITION + except ImportError: + return + + def _on_position(_event) -> None: + _fire_position_refresh_callback_debounced() + + self._ee.register(EVENT_POSITION, _on_position) + self._position_hooked = True + def available(self) -> bool: return self._engine is not None @@ -414,7 +449,7 @@ class CtpBridge: mode, self._td_logged_in(), len(self._engine.get_all_accounts() or [])) self._schedule_fee_sync(mode) - _fire_position_refresh_callback() + _fire_position_refresh_burst() return finally: self._ee.unregister(EVENT_LOG, _on_log)