diff --git a/app.py b/app.py index 791d47c..0399e92 100644 --- a/app.py +++ b/app.py @@ -1788,9 +1788,24 @@ def settings(): return redirect(url_for("settings")) flash("交易模式已保存") elif action == "ctp": + from ctp_settings import save_ctp_auto_connect, is_ctp_auto_connect_enabled from ctp_settings import save_ctp_settings_from_form + from vnpy_bridge import ctp_disconnect + was_enabled = is_ctp_auto_connect_enabled(get_setting) + auto_enabled = save_ctp_auto_connect(request.form, set_setting) save_result = save_ctp_settings_from_form(request.form, set_setting) + if not auto_enabled: + ctp_disconnect(set_disabled_hint=True) + elif not was_enabled and auto_enabled: + try: + from vnpy_bridge import get_bridge + from trading_context import get_trading_mode + + mode = get_trading_mode(get_setting) + get_bridge().reconnect_after_settings_saved(mode) + except Exception as exc: + app.logger.debug("CTP connect after enable auto: %s", exc) pwd_updated = save_result.get("passwords_updated") or [] pwd_empty = save_result.get("passwords_submitted_empty") or [] simnow_pwd_len = len((request.form.get("simnow_password") or "").strip()) @@ -1816,6 +1831,12 @@ def settings(): pwd_note = "实盘交易密码未改(提交为空)" else: pwd_note = "" + if not auto_enabled: + flash("CTP 配置已保存;自动连接已关闭,所有 CTP 连接已断开") + return redirect(url_for("settings")) + if not was_enabled: + flash("CTP 配置已保存;自动连接已开启,正在连接…") + return redirect(url_for("settings")) flash_msg = "CTP 配置已保存,正在使用新地址重连…" if pwd_note: flash_msg = f"CTP 配置已保存;{pwd_note},正在重连…" @@ -1864,7 +1885,7 @@ def settings(): ctp_st = ctp_status(get_trading_mode(get_setting)) except Exception: pass - from ctp_settings import get_ctp_settings_for_ui + from ctp_settings import get_ctp_settings_for_ui, is_ctp_auto_connect_enabled return render_template( "settings.html", @@ -1873,6 +1894,7 @@ def settings(): quote_label=get_quote_source_label(ctp_connected=bool(ctp_st.get("connected"))), ctp_status=ctp_st, ctp_cfg=get_ctp_settings_for_ui(), + ctp_auto_connect=is_ctp_auto_connect_enabled(get_setting), trading_mode=get_setting("trading_mode", "simulation"), position_sizing_mode=get_setting("position_sizing_mode", "fixed"), fixed_lots=get_setting("fixed_lots", "1"), diff --git a/ctp_premarket_connect.py b/ctp_premarket_connect.py index 214ba1d..2ec205d 100644 --- a/ctp_premarket_connect.py +++ b/ctp_premarket_connect.py @@ -12,6 +12,7 @@ import threading import time from typing import Callable +from ctp_settings import is_ctp_auto_connect_enabled from market_sessions import in_premarket_connect_window from vnpy_bridge import ctp_start_connect, ctp_status @@ -39,6 +40,7 @@ def _minutes_before_open() -> int: def start_ctp_premarket_connect_worker( *, get_mode_fn: Callable[[], str], + get_setting_fn: Callable[[str, str], str] | None = None, interval: int = CHECK_INTERVAL_SEC, ) -> None: """在交易开始前若干分钟自动发起 CTP 连接。""" @@ -47,8 +49,15 @@ def start_ctp_premarket_connect_worker( time.sleep(10) while True: try: - if _premarket_enabled() and in_premarket_connect_window( - minutes_before=_minutes_before_open(), + gs = get_setting_fn + if gs is None: + from fee_specs import get_setting as gs + if ( + is_ctp_auto_connect_enabled(gs) + and _premarket_enabled() + and in_premarket_connect_window( + minutes_before=_minutes_before_open(), + ) ): mode = get_mode_fn() st = ctp_status(mode) diff --git a/ctp_reconnect.py b/ctp_reconnect.py index 0d72824..022b356 100644 --- a/ctp_reconnect.py +++ b/ctp_reconnect.py @@ -12,6 +12,7 @@ import threading import time from typing import Callable +from ctp_settings import is_ctp_auto_connect_enabled from vnpy_bridge import ctp_try_auto_reconnect logger = logging.getLogger(__name__) @@ -27,13 +28,23 @@ def _auto_reconnect_enabled() -> bool: ) -def start_ctp_reconnect_worker(*, get_mode_fn: Callable[[], str], interval: int = RECONNECT_INTERVAL_SEC) -> None: +def start_ctp_reconnect_worker( + *, + get_mode_fn: Callable[[], str], + get_setting_fn: Callable[[str, str], str] | None = None, + interval: int = RECONNECT_INTERVAL_SEC, +) -> None: """定时检测 CTP 连接,断线后自动重连。""" def _loop() -> None: while True: try: - if _auto_reconnect_enabled(): + gs = get_setting_fn + if gs is None: + from fee_specs import get_setting as gs + if not is_ctp_auto_connect_enabled(gs): + pass + elif _auto_reconnect_enabled(): mode = get_mode_fn() if ctp_try_auto_reconnect(mode): logger.debug("CTP 连接正常 [%s]", mode) diff --git a/ctp_settings.py b/ctp_settings.py index d25dd43..e660bc4 100644 --- a/ctp_settings.py +++ b/ctp_settings.py @@ -34,6 +34,30 @@ LIVE_FIELDS: tuple[tuple[str, str, str, str], ...] = ( PASSWORD_DB_KEYS = frozenset({"simnow_password", "ctp_live_password"}) +CTP_AUTO_CONNECT_KEY = "ctp_auto_connect" +CTP_DISABLED_HINT = "CTP 自动连接已关闭(非交易时段建议关闭,避免反复连接失败)" + + +def is_ctp_auto_connect_enabled(get_setting=None) -> bool: + """系统设置:是否允许 CTP 连接(含自动重连、盘前连接、手动连接)。""" + if get_setting is None: + from fee_specs import get_setting as _gs + + get_setting = _gs + val = (get_setting(CTP_AUTO_CONNECT_KEY, "1") or "1").strip().lower() + return val in ("1", "true", "yes", "on") + + +def save_ctp_auto_connect(form: Any, set_setting: Callable[[str, str], None]) -> bool: + enabled = (form.get("ctp_auto_connect") or "").strip().lower() in ( + "1", + "on", + "true", + "yes", + ) + set_setting(CTP_AUTO_CONNECT_KEY, "1" if enabled else "0") + return enabled + def _get_db_setting(key: str, default: str = "") -> str: from fee_specs import get_setting @@ -85,6 +109,7 @@ def get_ctp_settings_for_ui() -> dict[str, Any]: if db_key in PASSWORD_DB_KEYS: ui[f"{db_key}_set"] = bool(ui[db_key]) ui[db_key] = "" + ui["ctp_auto_connect"] = is_ctp_auto_connect_enabled() return ui diff --git a/install_trading.py b/install_trading.py index 973bb50..caab656 100644 --- a/install_trading.py +++ b/install_trading.py @@ -34,6 +34,7 @@ from recommend_store import ( ) from recommend_stream import recommend_hub, schedule_recommend_refresh, start_recommend_worker from position_stream import position_hub, start_position_worker +from ctp_settings import is_ctp_auto_connect_enabled 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 @@ -1562,9 +1563,10 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se threading.Thread(target=_warm, daemon=True, name="position-bootstrap").start() try: - from vnpy_bridge import ctp_start_connect - mode = get_trading_mode(get_setting) - ctp_start_connect(mode, force=False) + if is_ctp_auto_connect_enabled(get_setting): + from vnpy_bridge import ctp_start_connect + mode = get_trading_mode(get_setting) + ctp_start_connect(mode, force=False) except Exception as exc: logger.debug("bootstrap ctp connect: %s", exc) @@ -1617,6 +1619,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se 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), + ctp_auto_connect=is_ctp_auto_connect_enabled(get_setting), recommend_rows=rec_cache.get("rows") or [], recommend_updated_at=rec_cache.get("updated_at"), product_categories=PRODUCT_CATEGORIES, @@ -2258,7 +2261,17 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se @login_required def api_ctp_connect(): from vnpy_bridge import ctp_start_connect + from ctp_settings import CTP_DISABLED_HINT + if not is_ctp_auto_connect_enabled(get_setting): + mode = get_trading_mode(get_setting) + st = ctp_status(mode) + return jsonify({ + "ok": False, + "disabled": True, + "error": CTP_DISABLED_HINT, + "status": st, + }), 400 mode = get_trading_mode(get_setting) body = request.get_json(silent=True) or {} force = bool(body.get("force")) @@ -2723,8 +2736,14 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se get_sizing_mode_fn=lambda: get_sizing_mode(get_setting), get_fixed_lots_fn=lambda: get_fixed_lots(get_setting), ) - start_ctp_reconnect_worker(get_mode_fn=lambda: get_trading_mode(get_setting)) - start_ctp_premarket_connect_worker(get_mode_fn=lambda: get_trading_mode(get_setting)) + start_ctp_reconnect_worker( + get_mode_fn=lambda: get_trading_mode(get_setting), + get_setting_fn=get_setting, + ) + start_ctp_premarket_connect_worker( + get_mode_fn=lambda: get_trading_mode(get_setting), + get_setting_fn=get_setting, + ) start_sl_tp_guard_worker( db_path=DB_PATH, get_mode_fn=lambda: get_trading_mode(get_setting), diff --git a/static/js/trade.js b/static/js/trade.js index f89e240..3f4dc1c 100644 --- a/static/js/trade.js +++ b/static/js/trade.js @@ -33,6 +33,7 @@ var hasSlTpMonitoring = false; var ctpConnected = false; var ctpConnecting = false; + var ctpAutoConnectEnabled = window.CTP_AUTO_CONNECT !== false; var positionsRendered = false; var selectedMaxLots = null; var recommendMaxByProduct = {}; @@ -183,6 +184,10 @@ ctpConnecting = !!connecting; isTradingSession = !!data.trading_session; syncCtpBadgeFromStatus(data.ctp_status || { connected: connected, connecting: connecting }); + if (data.ctp_status && typeof data.ctp_status.auto_connect_enabled === 'boolean') { + ctpAutoConnectEnabled = data.ctp_status.auto_connect_enabled; + updateCtpConnectButtonState(); + } if (syncBadge) { if (data.sync_label && connected) { syncBadge.hidden = false; @@ -200,6 +205,8 @@ } else if (isCtpUnreachableError(data.ctp_status.last_error)) { lastCtpUnreachableAt = Date.now(); } + } else if (!connected && data.ctp_status && data.ctp_status.disabled_hint) { + showCtpError(data.ctp_status.disabled_hint); } var riskBadge = document.getElementById('risk-badge'); if (riskBadge && data.risk_status) { @@ -235,14 +242,20 @@ list.innerHTML = '
' + err + '
'; return; } + if (!ctpAutoConnectEnabled) { + var offHint = (data.ctp_status && data.ctp_status.disabled_hint) || + 'CTP 自动连接已关闭,请在系统设置中开启'; + list.innerHTML = '
' + offHint + '
'; + return; + } list.innerHTML = '
CTP 未连接,正在尝试自动重连…
'; - tryAutoCtpReconnect(); + if (ctpAutoConnectEnabled) tryAutoCtpReconnect(); return; } list.innerHTML = '
暂无持仓。
'; return; } - if (!connected) { + if (!connected && ctpAutoConnectEnabled) { tryAutoCtpReconnect(); } list.innerHTML = rows.map(buildPosCard).join(''); @@ -350,11 +363,28 @@ updateCtpBadge(connected, connecting); } + function updateCtpConnectButtonState() { + var btnConnect = document.getElementById('btn-ctp-connect'); + var hint = document.getElementById('ctp-auto-hint'); + if (hint) { + hint.textContent = ctpAutoConnectEnabled + ? '断线自动重连 · 开盘前 30 分钟自动连接' + : 'CTP 自动连接已关闭(系统设置可开启)'; + } + if (btnConnect && !ctpAutoConnectEnabled) { + btnConnect.disabled = true; + btnConnect.title = '请先在系统设置 → CTP 连接 中开启自动连接'; + } + } + function updateCtpBadge(connected, connecting) { var ctpBadge = document.getElementById('ctp-badge'); var btnConnect = document.getElementById('btn-ctp-connect'); if (ctpBadge) { - if (connecting) { + if (!ctpAutoConnectEnabled && !connected) { + ctpBadge.textContent = 'CTP 已关闭'; + ctpBadge.className = 'badge planned'; + } else if (connecting) { ctpBadge.textContent = 'CTP 连接中'; ctpBadge.className = 'badge planned'; } else { @@ -363,11 +393,17 @@ } } if (btnConnect) { - if (connecting) { + if (!ctpAutoConnectEnabled) { + btnConnect.textContent = connected ? '重连 CTP' : '连接 CTP'; + btnConnect.disabled = true; + btnConnect.title = '请先在系统设置 → CTP 连接 中开启自动连接'; + } else if (connecting) { btnConnect.textContent = '连接中…'; btnConnect.disabled = true; + btnConnect.title = ''; } else { btnConnect.disabled = false; + btnConnect.title = ''; btnConnect.textContent = connected ? '重连 CTP' : '连接 CTP'; } } @@ -418,6 +454,10 @@ } function requestCtpConnect(force) { + if (!force && !ctpAutoConnectEnabled) { + showCtpError('CTP 自动连接已关闭,请在系统设置中开启'); + return Promise.resolve({ ok: false, disabled: true }); + } if (!force && ctpConnectInflight) { return Promise.resolve({}); } @@ -449,6 +489,13 @@ return d; }); } + if (d.disabled || st.auto_connect_enabled === false) { + ctpAutoConnectEnabled = false; + updateCtpConnectButtonState(); + syncCtpBadgeFromStatus(st); + showCtpError(st.disabled_hint || d.error || 'CTP 自动连接已关闭'); + return d; + } if (!d.ok) { syncCtpBadgeFromStatus(st); var err = d.error || st.last_error || '连接失败'; @@ -564,6 +611,7 @@ } function tryAutoCtpReconnect() { + if (!ctpAutoConnectEnabled) return; if (ctpReconnecting || ctpConnectInflight) return; var now = Date.now(); if (now - lastCtpReconnectAt < 60000) return; @@ -1517,14 +1565,23 @@ .then(function (r) { return r.json(); }) .then(function (d) { var st = d.status || {}; + if (typeof st.auto_connect_enabled === 'boolean') { + ctpAutoConnectEnabled = st.auto_connect_enabled; + } + updateCtpConnectButtonState(); syncCtpBadgeFromStatus(st); - if (st.last_error) showCtpError(st.last_error); + if (st.disabled_hint) { + showCtpError(st.disabled_hint); + } else if (st.last_error) { + showCtpError(st.last_error); + } if (st.connected) pollPositions(); }) .catch(function () {}); } runWhenReady(function () { + updateCtpConnectButtonState(); setPriceType('limit'); if (isFixedMode() && lotsCalc) { lotsCalc.value = String(window.TRADE_FIXED_LOTS || 1); diff --git a/templates/settings.html b/templates/settings.html index 0a1d05c..5fdb34f 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -200,11 +200,13 @@ {% call settings_card('ctp', 'CTP 连接', 'settings-ctp-wrap') %}

- 投资者代码、密码、前置地址在此维护(优先于 .env)。保存后将自动断开并用新地址重连 CTP。 + 投资者代码、密码、前置地址在此维护(优先于 .env)。保存后将自动断开并用新地址重连 CTP(须开启下方自动连接)。 {% if ctp_status.connected %} 已连接 {% elif ctp_status.connecting %} 连接中 + {% elif ctp_status.disabled_hint %} + {{ ctp_status.disabled_hint }} {% elif ctp_status.last_error %} {{ ctp_status.last_error }} {% endif %} @@ -213,6 +215,20 @@

+
+ +
+
- - 断线自动重连 · 开盘前 30 分钟自动连接 + + {% if ctp_auto_connect %}断线自动重连 · 开盘前 30 分钟自动连接{% else %}CTP 自动连接已关闭{% endif %}
@@ -228,6 +231,7 @@ window.TRADE_FIXED_LOTS = {{ fixed_lots|tojson }}; window.TRADE_FIXED_AMOUNT = {{ fixed_amount|tojson }}; window.PRODUCT_CATEGORIES = {{ product_categories | default([]) | tojson }}; window.__RECOMMEND_ROWS__ = {{ recommend_rows | default([]) | tojson }}; +window.CTP_AUTO_CONNECT = {{ ctp_auto_connect | tojson }}; {% endblock %} diff --git a/vnpy_bridge.py b/vnpy_bridge.py index d794f2a..738668b 100644 --- a/vnpy_bridge.py +++ b/vnpy_bridge.py @@ -546,6 +546,12 @@ class CtpBridge: } def connect(self, mode: str, *, force: bool = False) -> None: + from ctp_settings import CTP_DISABLED_HINT, is_ctp_auto_connect_enabled + + if not is_ctp_auto_connect_enabled(): + self._last_error = CTP_DISABLED_HINT + _persist_last_error(CTP_DISABLED_HINT) + raise RuntimeError(CTP_DISABLED_HINT) if self._connect_in_progress: raise RuntimeError("CTP 正在连接中,请稍候") if self._is_login_cooldown_active() and not force: @@ -644,6 +650,18 @@ class CtpBridge: def start_connect_async(self, mode: str, *, force: bool = False) -> dict[str, Any]: """后台连接,不阻塞 HTTP 请求。""" + from ctp_settings import CTP_DISABLED_HINT, is_ctp_auto_connect_enabled + + if not is_ctp_auto_connect_enabled(): + self._last_error = CTP_DISABLED_HINT + _persist_last_error(CTP_DISABLED_HINT) + return { + "started": False, + "connecting": False, + "connected": False, + "disabled": True, + "error": CTP_DISABLED_HINT, + } if self._connected_mode == mode and self.ping() and not force: return {"started": False, "connecting": False, "connected": True} if self._connect_in_progress: @@ -787,9 +805,13 @@ class CtpBridge: def reconnect_after_settings_saved(self, mode: str) -> dict[str, Any]: """保存前置/账号后关闭旧连接,并用数据库中的新配置重连。""" + from ctp_settings import is_ctp_auto_connect_enabled + self._close_gateway() self._last_error = "" _persist_last_error("") + if not is_ctp_auto_connect_enabled(): + return {"started": False, "connecting": False, "connected": False, "disabled": True} return self.start_connect_async(mode, force=True) def _schedule_fee_sync(self, mode: str) -> None: @@ -1623,6 +1645,20 @@ def vnpy_available() -> bool: return get_bridge().available() +def ctp_disconnect(*, set_disabled_hint: bool = False) -> None: + """主动断开 CTP 并清理内存状态。""" + from ctp_settings import CTP_DISABLED_HINT + + b = get_bridge() + b._close_gateway() + if set_disabled_hint: + b._last_error = CTP_DISABLED_HINT + _persist_last_error(CTP_DISABLED_HINT) + else: + b._last_error = "" + _persist_last_error("") + + def ctp_connect(mode: str, *, force: bool = False) -> dict[str, Any]: b = get_bridge() b.connect(mode, force=force) @@ -1639,6 +1675,10 @@ def ctp_start_connect(mode: str, *, force: bool = False) -> dict[str, Any]: def ctp_try_auto_reconnect(mode: str) -> bool: """断线时静默异步重连;已连接且交易通道正常则不再重复 connect。""" + from ctp_settings import is_ctp_auto_connect_enabled + + if not is_ctp_auto_connect_enabled(): + return False b = get_bridge() if not b.available(): return False @@ -1673,7 +1713,17 @@ def ctp_try_auto_reconnect(mode: str) -> bool: def ctp_status(mode: str) -> dict[str, Any]: + from ctp_settings import CTP_DISABLED_HINT, is_ctp_auto_connect_enabled + + auto = is_ctp_auto_connect_enabled() st = get_bridge().status(mode) + st["auto_connect_enabled"] = auto + if not auto: + st["disabled_hint"] = CTP_DISABLED_HINT + if not st.get("connected") and not st.get("connecting"): + st["last_error"] = "" + st["td_reachable"] = None + return st if not st.get("connected") and not st.get("connecting"): setting = _setting_for_mode(mode) td = setting.get("交易服务器", "")