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 = '
- 投资者代码、密码、前置地址在此维护(优先于 .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 @@