From 259d9e812d0452345d881225d03fdba7d3847459 Mon Sep 17 00:00:00 2001 From: dekun Date: Thu, 25 Jun 2026 17:01:36 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20CTP=E7=99=BB=E5=BD=95=E5=86=B7=E5=8D=B4?= =?UTF-8?q?=E6=8C=81=E4=B9=85=E5=8C=96=E5=88=B0=E6=95=B0=E6=8D=AE=E5=BA=93?= =?UTF-8?q?=EF=BC=8C=E5=8F=96=E6=B6=88=E9=A1=B5=E9=9D=A2=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E8=BF=9E=E5=B9=B6=E5=88=B7=E6=96=B0JS=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- app.py | 15 ++++++++-- static/js/trade.js | 10 ++----- templates/trade.html | 2 +- vnpy_bridge.py | 70 ++++++++++++++++++++++++++++++++++++++++---- 4 files changed, 80 insertions(+), 17 deletions(-) diff --git a/app.py b/app.py index 2b25e8f..0c081c5 100644 --- a/app.py +++ b/app.py @@ -205,7 +205,9 @@ def require_nav(key: str): @app.context_processor def inject_globals(): - return {"nav_items": get_nav_items(get_setting)} + trade_js = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static", "js", "trade.js") + asset_v = str(int(os.path.getmtime(trade_js))) if os.path.isfile(trade_js) else "0" + return {"nav_items": get_nav_items(get_setting), "asset_v": asset_v} def _trading_mode() -> str: @@ -1714,9 +1716,16 @@ def settings(): save_ctp_settings_from_form(request.form, set_setting) try: - from vnpy_bridge import get_bridge + from vnpy_bridge import get_bridge, _persist_last_error - get_bridge().mark_disconnected() + b = get_bridge() + b.mark_disconnected() + if (request.form.get("simnow_password") or "").strip() or ( + request.form.get("ctp_live_password") or "" + ).strip(): + b._clear_login_cooldown() + b._last_error = "" + _persist_last_error("") except Exception: pass flash("CTP 配置已保存,请在持仓监控页重连 CTP") diff --git a/static/js/trade.js b/static/js/trade.js index 6cf246c..f2cb456 100644 --- a/static/js/trade.js +++ b/static/js/trade.js @@ -394,7 +394,6 @@ return Promise.resolve({}); } ctpConnectInflight = true; - updateCtpBadge(false, true); return fetch('/api/ctp/connect', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -415,6 +414,7 @@ return d; } if (d.connecting || st.connecting) { + updateCtpBadge(false, true); return waitForCtpConnected(70000).then(function (ok) { if (!ok && d.error) showCtpError(d.error); else if (!ok && st.last_error) showCtpError(st.last_error); @@ -1086,13 +1086,7 @@ var st = d.status || {}; syncCtpBadgeFromStatus(st); if (st.last_error) showCtpError(st.last_error); - if (st.connected) { - pollPositions(); - return; - } - if ((st.login_cooldown_sec || 0) > 0) return; - if (isCtpLoginBanError(st.last_error)) return; - if (!st.connecting) requestCtpConnect(false); + if (st.connected) pollPositions(); }) .catch(function () {}); } diff --git a/templates/trade.html b/templates/trade.html index b1e20c3..37b2052 100644 --- a/templates/trade.html +++ b/templates/trade.html @@ -161,5 +161,5 @@ window.TRADE_SIZING_MODE = {{ sizing_mode|tojson }}; window.TRADE_FIXED_LOTS = {{ fixed_lots|tojson }}; window.TRADE_FIXED_AMOUNT = {{ fixed_amount|tojson }}; - + {% endblock %} diff --git a/vnpy_bridge.py b/vnpy_bridge.py index 496d66e..3269221 100644 --- a/vnpy_bridge.py +++ b/vnpy_bridge.py @@ -24,6 +24,48 @@ CONNECT_WAIT_SEC = 60 CONNECT_POLL_INTERVAL_SEC = 0.5 LOGIN_BAN_COOLDOWN_SEC = 45 * 60 LOGIN_FAIL_COOLDOWN_SEC = 5 * 60 +CTP_COOLDOWN_UNTIL_KEY = "ctp_login_cooldown_until" +CTP_LAST_ERROR_KEY = "ctp_last_error" + + +def _persist_login_cooldown(seconds: float) -> None: + from fee_specs import get_setting, set_setting + + new_until = time.time() + max(0.0, seconds) + try: + old = float(get_setting(CTP_COOLDOWN_UNTIL_KEY, "0") or 0) + except (TypeError, ValueError): + old = 0.0 + if new_until > old: + set_setting(CTP_COOLDOWN_UNTIL_KEY, str(new_until)) + + +def _persisted_login_cooldown_remaining() -> int: + from fee_specs import get_setting + + try: + until = float(get_setting(CTP_COOLDOWN_UNTIL_KEY, "0") or 0) + return max(0, int(until - time.time())) + except (TypeError, ValueError): + return 0 + + +def _clear_persisted_login_cooldown() -> None: + from fee_specs import set_setting + + set_setting(CTP_COOLDOWN_UNTIL_KEY, "0") + + +def _persist_last_error(msg: str) -> None: + from fee_specs import set_setting + + set_setting(CTP_LAST_ERROR_KEY, (msg or "").strip()) + + +def _load_persisted_last_error() -> str: + from fee_specs import get_setting + + return (get_setting(CTP_LAST_ERROR_KEY, "") or "").strip() _position_refresh_callback: Optional[Callable[[], None]] = None @@ -138,6 +180,7 @@ class CtpBridge: self._connect_lock = threading.Lock() self._connect_in_progress = False self._login_cooldown_until: float = 0.0 + self._restore_persisted_state() self._commission_waiters: dict[int, threading.Event] = {} self._commission_lists: dict[int, list] = {} self._commission_hooked = False @@ -180,10 +223,18 @@ class CtpBridge: def connect_in_progress(self) -> bool: return self._connect_in_progress + def _restore_persisted_state(self) -> None: + err = _load_persisted_last_error() + if err: + self._last_error = err + db_remain = _persisted_login_cooldown_remaining() + if db_remain > 0: + self._login_cooldown_until = time.monotonic() + db_remain + def login_cooldown_remaining(self) -> int: - """距允许再次登录的剩余秒数。""" - remain = int(self._login_cooldown_until - time.monotonic()) - return max(0, remain) + """距允许再次登录的剩余秒数(内存 + 数据库,重启后仍有效)。""" + mem = max(0, int(self._login_cooldown_until - time.monotonic())) + return max(mem, _persisted_login_cooldown_remaining()) def _is_login_cooldown_active(self) -> bool: return self.login_cooldown_remaining() > 0 @@ -192,6 +243,11 @@ class CtpBridge: until = time.monotonic() + max(0.0, seconds) if until > self._login_cooldown_until: self._login_cooldown_until = until + _persist_login_cooldown(seconds) + + def _clear_login_cooldown(self) -> None: + self._login_cooldown_until = 0.0 + _clear_persisted_login_cooldown() def _apply_login_failure_cooldown(self, ctp_logs: list[str]) -> None: text = "\n".join(ctp_logs) @@ -253,6 +309,7 @@ class CtpBridge: missing = [k for k in ("用户名", "密码", "交易服务器") if not st.get(k)] cooldown = self.login_cooldown_remaining() connecting = bool(self._connect_in_progress and cooldown <= 0) + last_error = self._last_error or _load_persisted_last_error() return { "vnpy_installed": self.available(), "connected": self._connected_mode == mode, @@ -260,8 +317,8 @@ class CtpBridge: "connected_mode": self._connected_mode, "mode_label": _mode_label(mode), "missing_config": missing, - "last_error": self._last_error, - "login_cooldown_sec": self.login_cooldown_remaining(), + "last_error": last_error, + "login_cooldown_sec": cooldown, "broker_id": st.get("经纪商代码", ""), "td_address": st.get("交易服务器", ""), } @@ -336,6 +393,8 @@ class CtpBridge: if self._wait_connected(mode, ctp_logs): self._connected_mode = mode self._last_error = "" + _persist_last_error("") + self._clear_login_cooldown() logger.info("CTP 已连接 [%s] td_login=%s accounts=%s", mode, self._td_logged_in(), len(self._engine.get_all_accounts() or [])) @@ -354,6 +413,7 @@ class CtpBridge: self._apply_login_failure_cooldown(ctp_logs) hint = _format_ctp_failure(ctp_logs, td_address=setting.get("交易服务器", "")) self._last_error = hint + _persist_last_error(hint) logger.warning("CTP 连接失败 [%s]: %s | logs=%s", mode, hint, ctp_logs[-5:]) raise RuntimeError(hint) finally: