From b4250171d586285e830367246424c313cb5d3fb2 Mon Sep 17 00:00:00 2001 From: dekun Date: Wed, 24 Jun 2026 11:56:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20CTP=20=E6=96=AD=E7=BA=BF=E9=87=8D?= =?UTF-8?q?=E8=BF=9E=E3=80=81=E4=B8=8B=E5=8D=95=E5=8D=A1=E7=89=87=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E3=80=81=E6=89=8B=E6=95=B0=E8=87=AA=E5=8A=A8=E8=AE=A1?= =?UTF-8?q?=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后台每 30s 检测并重连;以损定仓填止损后自动算手数;开仓/平仓按钮并排对齐。 Co-authored-by: Cursor --- .env.example | 3 ++ ctp_reconnect.py | 40 ++++++++++++++++++ install_trading.py | 2 + static/css/trade.css | 18 ++++---- static/js/trade.js | 99 ++++++++++++++++++++++++++++++++++---------- templates/trade.html | 16 +++---- vnpy_bridge.py | 44 ++++++++++++++++++-- 7 files changed, 177 insertions(+), 45 deletions(-) create mode 100644 ctp_reconnect.py diff --git a/.env.example b/.env.example index 6924276..66d0efd 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,9 @@ TRADING_MODE=simulation POSITION_SIZING_MODE=risk RISK_PERCENT=1 +# CTP 断线后后台自动重连(true/false) +CTP_AUTO_RECONNECT=true + # —— SimNow 模拟盘(注册见 docs/SIMNOW.md)—— SIMNOW_USER= SIMNOW_PASSWORD= diff --git a/ctp_reconnect.py b/ctp_reconnect.py new file mode 100644 index 0000000..494588b --- /dev/null +++ b/ctp_reconnect.py @@ -0,0 +1,40 @@ +"""CTP 断线自动重连(后台线程)。""" +from __future__ import annotations + +import logging +import os +import threading +import time +from typing import Callable + +from vnpy_bridge import ctp_try_auto_reconnect + +logger = logging.getLogger(__name__) + +RECONNECT_INTERVAL_SEC = 30 + + +def _auto_reconnect_enabled() -> bool: + return (os.getenv("CTP_AUTO_RECONNECT", "true") or "true").strip().lower() in ( + "1", + "true", + "yes", + ) + + +def start_ctp_reconnect_worker(*, get_mode_fn: Callable[[], str], interval: int = RECONNECT_INTERVAL_SEC) -> None: + """定时检测 CTP 连接,断线后自动重连。""" + + def _loop() -> None: + time.sleep(5) + while True: + try: + if _auto_reconnect_enabled(): + mode = get_mode_fn() + if ctp_try_auto_reconnect(mode): + logger.debug("CTP 连接正常 [%s]", mode) + except Exception as exc: + logger.warning("CTP reconnect worker: %s", exc) + time.sleep(max(15, interval)) + + threading.Thread(target=_loop, daemon=True, name="ctp-reconnect-worker").start() diff --git a/install_trading.py b/install_trading.py index e706b1f..e070d53 100644 --- a/install_trading.py +++ b/install_trading.py @@ -19,6 +19,7 @@ from position_sizing import ( ) from recommend_store import load_recommend_cache, refresh_recommend_cache from recommend_stream import recommend_hub, start_recommend_worker +from ctp_reconnect import start_ctp_reconnect_worker from risk.account_risk_lib import ( assert_can_open, get_risk_status, @@ -918,3 +919,4 @@ def install_trading(app, *, login_required, get_db, get_setting, set_setting, fe price_fn=_main_price, init_tables_fn=_init_tables, ) + start_ctp_reconnect_worker(get_mode_fn=lambda: get_trading_mode(get_setting)) diff --git a/static/css/trade.css b/static/css/trade.css index 53162d8..dc3141c 100644 --- a/static/css/trade.css +++ b/static/css/trade.css @@ -10,19 +10,17 @@ .trade-order-status{display:grid;gap:.55rem;margin:.5rem 0 .75rem;padding:.65rem .85rem;background:var(--card-inner);border:1px solid var(--card-border);border-radius:8px;font-size:.82rem} .trade-order-status-compact{margin-top:0} .trade-order-status .status-row{display:flex;flex-wrap:wrap;align-items:center;gap:.35rem .65rem} -.trade-form-grid{display:grid;grid-template-columns:1fr 1fr;gap:.65rem;margin-bottom:.75rem} +.trade-form-grid{display:grid;grid-template-columns:1fr 1fr;gap:.75rem .65rem;margin-bottom:.85rem} .trade-form-grid .span-2{grid-column:span 2} -.trade-field label{display:block;font-size:.72rem;margin-bottom:.25rem;color:var(--text-label)} -.trade-field select,.trade-field input{width:100%} +.trade-field label{display:block;font-size:.72rem;margin-bottom:.28rem;color:var(--text-label)} +.trade-field select,.trade-field input{width:100%;box-sizing:border-box} +.trade-field .lots-auto{color:var(--accent);font-weight:600;background:var(--card-inner);cursor:default} .price-type-tabs{display:flex;gap:.35rem;margin-bottom:.35rem} -.price-tab{border:1px solid var(--card-border);background:var(--card-inner);color:var(--text-muted);padding:.25rem .65rem;border-radius:6px;font-size:.75rem;cursor:pointer} -.price-tab.active{border-color:var(--accent);color:var(--accent);font-weight:600} +.price-tab{border:1px solid var(--card-border);background:var(--card-inner);color:var(--text-muted);padding:.28rem .7rem;border-radius:6px;font-size:.75rem;cursor:pointer;flex:1;text-align:center} +.price-tab.active{border-color:var(--accent);color:var(--accent);font-weight:600;background:rgba(56,189,248,.08)} .market-hint{font-size:.7rem;margin-top:.25rem} -.calc-lots-row{display:flex;gap:.4rem} -.calc-lots-row input{flex:1} -.calc-lots-row .btn-secondary{padding:.35rem .6rem;font-size:.75rem;white-space:nowrap} -.trade-action-row{display:flex;gap:.65rem;margin:.75rem 0 .5rem} -.trade-action-row .btn-open{flex:1;padding:.65rem} +.trade-action-row{display:grid;grid-template-columns:1fr 1fr;gap:.65rem;margin:.85rem 0 .55rem} +.trade-action-row .btn-open,.trade-action-row .btn-secondary{padding:.6rem .75rem;font-size:.88rem;width:100%} .trade-footer{background:var(--card-inner);border-radius:8px;padding:.65rem .85rem;font-size:.78rem;line-height:1.5;border:1px solid var(--card-border);margin-top:.5rem} .trade-footer strong{color:var(--accent)} .rec-blocked td{opacity:.55} diff --git a/static/js/trade.js b/static/js/trade.js index 4473f70..ff261f8 100644 --- a/static/js/trade.js +++ b/static/js/trade.js @@ -14,8 +14,11 @@ var pollTimer = null; var recommendSource = null; var quoteTimer = null; + var calcTimer = null; var lastQuotePrice = null; var priceType = 'limit'; + var lastCtpReconnectAt = 0; + var ctpReconnecting = false; function runWhenReady(fn) { if (document.readyState === 'loading') { @@ -63,6 +66,18 @@ if (marketHint) marketHint.hidden = priceType !== 'market'; } + function updateCtpBadge(connected) { + var ctpBadge = document.getElementById('ctp-badge'); + var btnConnect = document.getElementById('btn-ctp-connect'); + if (ctpBadge) { + ctpBadge.textContent = connected ? 'CTP 已连接' : 'CTP 未连接'; + ctpBadge.className = 'badge ' + (connected ? 'profit' : 'planned'); + } + if (btnConnect && connected) { + btnConnect.textContent = '重连 CTP'; + } + } + function refreshQuote() { var sym = selectedSymbol(); var lots = isRiskMode() ? (effectiveLots() || 1) : (lotsInput ? lotsInput.value : '1'); @@ -83,6 +98,7 @@ '' + (data.name || sym) + ' 精度 ' + m.price_precision + ' 位 · 每跳 ' + m.tick_value_total + ' 元(' + lots + ' 手)'; } + scheduleAutoCalc(); }).catch(function () {}); } @@ -91,14 +107,23 @@ quoteTimer = setTimeout(refreshQuote, 400); } - function calcLotsPreview() { + function scheduleAutoCalc() { + if (!isRiskMode()) return; + clearTimeout(calcTimer); + calcTimer = setTimeout(autoCalcLots, 450); + } + + function autoCalcLots() { + if (!isRiskMode() || !lotsCalc) return; var sym = selectedSymbol(); var entry = entryPrice() || parseFloat(priceInput && priceInput.value) || 0; var sl = parseFloat(slInput && slInput.value) || 0; if (!sym || !entry || !sl) { - alert('请填写品种、入场价与止损'); + lotsCalc.value = ''; + lotsCalc.placeholder = '填写止损后自动计算'; return; } + lotsCalc.placeholder = '计算中…'; fetch('/api/trade/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -111,12 +136,47 @@ take_profit: parseFloat(tpInput && tpInput.value) || 0 }) }).then(function (r) { return r.json(); }).then(function (data) { - if (!data.ok) { alert(data.error || '计算失败'); return; } - if (lotsCalc) lotsCalc.value = data.lots; + if (!data.ok) { + lotsCalc.value = ''; + lotsCalc.placeholder = data.error || '无法计算'; + return; + } + lotsCalc.value = data.lots; + lotsCalc.placeholder = '填写止损后自动计算'; scheduleQuote(); + }).catch(function () { + lotsCalc.placeholder = '计算失败'; }); } + function tryAutoCtpReconnect() { + if (ctpReconnecting) return; + var now = Date.now(); + if (now - lastCtpReconnectAt < 30000) return; + lastCtpReconnectAt = now; + ctpReconnecting = true; + fetch('/api/ctp/connect', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ auto: true }) + }) + .then(function (r) { return r.json(); }) + .then(function (d) { + if (d.ok && d.status && d.status.connected) { + updateCtpBadge(true); + var avail = document.getElementById('avail-display'); + if (avail && d.account && d.account.available != null) { + avail.textContent = Number(d.account.available).toFixed(2); + } + pollPositions(); + } + }) + .catch(function () { /* ignore */ }) + .finally(function () { + ctpReconnecting = false; + }); + } + function postOrder(offset) { var sym = selectedSymbol(); if (!sym) { alert('请选择品种'); return; } @@ -129,7 +189,7 @@ var lots = effectiveLots(); if (offset === 'open') { if (isRiskMode() && lots <= 0) { - alert('请先点击「计算」得到手数'); + alert('请填写止损,系统将自动计算手数'); return; } if (!isRiskMode() && lots <= 0) { @@ -216,14 +276,12 @@ .then(function (data) { var cap = document.getElementById('cap-display'); if (cap && data.capital != null) cap.textContent = Number(data.capital).toFixed(2); - var ctpBadge = document.getElementById('ctp-badge'); - if (ctpBadge && data.ctp_status) { - ctpBadge.textContent = data.ctp_status.connected ? 'CTP 已连接' : 'CTP 未连接'; - ctpBadge.className = 'badge ' + (data.ctp_status.connected ? 'profit' : 'planned'); - } + var connected = data.ctp_status && data.ctp_status.connected; + updateCtpBadge(!!connected); var rows = data.rows || []; - if (!data.ctp_status || !data.ctp_status.connected) { - list.innerHTML = '
请先连接 CTP,持仓将显示柜台实际数据。
'; + if (!connected) { + list.innerHTML = '
CTP 未连接,正在尝试自动重连…
'; + tryAutoCtpReconnect(); return; } if (!rows.length) { @@ -286,20 +344,18 @@ }); }); - if (symInput) symInput.addEventListener('input', scheduleQuote); + if (symInput) symInput.addEventListener('input', function () { scheduleQuote(); scheduleAutoCalc(); }); if (lotsInput) lotsInput.addEventListener('input', scheduleQuote); - if (slInput) slInput.addEventListener('input', function () { - if (isRiskMode() && lotsCalc) lotsCalc.value = ''; - }); + if (slInput) slInput.addEventListener('input', scheduleAutoCalc); + if (tpInput) tpInput.addEventListener('input', scheduleAutoCalc); + if (dirSelect) dirSelect.addEventListener('change', scheduleAutoCalc); if (priceInput) { priceInput.addEventListener('input', function () { if (priceType === 'limit') priceInput.dataset.manual = '1'; + scheduleAutoCalc(); }); } - var btnCalc = document.getElementById('btn-calc-lots'); - if (btnCalc) btnCalc.addEventListener('click', calcLotsPreview); - var btnOpen = document.getElementById('btn-open'); var btnClose = document.getElementById('btn-close-pos'); if (btnOpen) btnOpen.addEventListener('click', function () { postOrder('open'); }); @@ -314,11 +370,12 @@ .then(function (r) { return r.json(); }) .then(function (d) { if (!d.ok) { alert(d.error || '连接失败'); return; } - location.reload(); + updateCtpBadge(true); + pollPositions(); }) .finally(function () { btnConnect.disabled = false; - btnConnect.textContent = '连接 CTP'; + btnConnect.textContent = '重连 CTP'; }); }); } diff --git a/templates/trade.html b/templates/trade.html index 5789d16..ed2a87d 100644 --- a/templates/trade.html +++ b/templates/trade.html @@ -15,7 +15,8 @@ {% if ctp_account.available is defined and ctp_status.connected %} 可用 {{ '%.2f'|format(ctp_account.available) }} {% endif %} - + + 断线自动重连
@@ -46,9 +47,10 @@
-
+
- + +
@@ -69,14 +71,6 @@
- -
- -
- - -
-
diff --git a/vnpy_bridge.py b/vnpy_bridge.py index 4230cf0..4bb7a76 100644 --- a/vnpy_bridge.py +++ b/vnpy_bridge.py @@ -115,6 +115,8 @@ class CtpBridge: return self._connected_mode def status(self, mode: str) -> dict[str, Any]: + if self._connected_mode == mode: + self.ping() st = _setting_for_mode(mode) missing = [k for k in ("用户名", "密码", "交易服务器") if not st.get(k)] return { @@ -132,7 +134,9 @@ class CtpBridge: if not self._engine: raise RuntimeError(self._last_error or "vnpy 引擎未初始化") if self._connected_mode == mode and not force: - return + if self.ping(): + return + self._connected_mode = None setting = _setting_for_mode(mode) if not setting.get("用户名") or not setting.get("密码"): raise ValueError( @@ -190,8 +194,24 @@ class CtpBridge: raise RuntimeError(hint) def ensure_connected(self, mode: str) -> None: - if self._connected_mode != mode: - self.connect(mode) + if self._connected_mode == mode and self.ping(): + return + self.connect(mode) + + def ping(self) -> bool: + """检测连接是否仍有效;无效则清除 connected 状态。""" + if not self._engine or not self._connected_mode: + return False + try: + if self._engine.get_all_accounts(): + return True + except Exception as exc: + logger.debug("CTP ping failed: %s", exc) + self._connected_mode = None + return False + + def mark_disconnected(self) -> None: + self._connected_mode = None def get_account(self) -> dict[str, Any]: if not self._engine: @@ -310,6 +330,24 @@ def ctp_connect(mode: str, *, force: bool = False) -> dict[str, Any]: return b.status(mode) +def ctp_try_auto_reconnect(mode: str) -> bool: + """断线时静默重连;已连接且 ping 正常则直接返回 True。""" + b = get_bridge() + if not b.available(): + return False + st = _setting_for_mode(mode) + if not st.get("用户名") or not st.get("密码") or not st.get("交易服务器"): + return False + if b.connected_mode == mode and b.ping(): + return True + try: + b.connect(mode, force=True) + return b.connected_mode == mode + except Exception as exc: + logger.info("CTP 自动重连失败: %s", exc) + return False + + def ctp_status(mode: str) -> dict[str, Any]: return get_bridge().status(mode)