From 5a6c89c66268dbd6948c533cc423287338f76d3d Mon Sep 17 00:00:00 2001 From: dekun Date: Thu, 25 Jun 2026 16:46:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20CTP/SimNow=20=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E8=BF=81=E5=85=A5=E7=B3=BB=E7=BB=9F=E8=AE=BE=E7=BD=AE=EF=BC=8C?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E5=A4=B1=E8=B4=A5=E5=8D=B3=E6=97=B6=E6=8A=A5?= =?UTF-8?q?=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .env.example | 2 +- app.py | 18 ++++++ ctp_settings.py | 108 ++++++++++++++++++++++++++++++++++++ docs/SIMNOW.md | 2 +- scripts/test_simnow.py | 19 ++++--- templates/settings.html | 118 +++++++++++++++++++++++++++++++++++++++- vnpy_bridge.py | 39 ++++--------- 7 files changed, 267 insertions(+), 39 deletions(-) create mode 100644 ctp_settings.py diff --git a/.env.example b/.env.example index 0f9f43b..f288cec 100644 --- a/.env.example +++ b/.env.example @@ -22,7 +22,7 @@ RISK_PERCENT=1 # CTP 断线后后台自动重连(true/false) CTP_AUTO_RECONNECT=true -# —— SimNow 模拟盘(注册见 docs/SIMNOW.md)—— +# —— SimNow 模拟盘(也可在「系统设置 → CTP 连接」配置,优先于本文件)—— SIMNOW_USER= SIMNOW_PASSWORD= SIMNOW_BROKER_ID=9999 diff --git a/app.py b/app.py index 1bf39b1..2b25e8f 100644 --- a/app.py +++ b/app.py @@ -366,6 +366,9 @@ def init_db(): if not get_setting("ths_refresh_token") and os.getenv("THS_REFRESH_TOKEN"): set_setting("ths_refresh_token", os.getenv("THS_REFRESH_TOKEN")) + from ctp_settings import seed_ctp_settings_from_env + seed_ctp_settings_from_env(set_setting) + os.makedirs(UPLOAD_DIR, exist_ok=True) expire_old_plans() @@ -1706,6 +1709,17 @@ def settings(): flash("移动保本缓冲无效") return redirect(url_for("settings")) flash("交易模式已保存") + elif action == "ctp": + from ctp_settings import save_ctp_settings_from_form + + save_ctp_settings_from_form(request.form, set_setting) + try: + from vnpy_bridge import get_bridge + + get_bridge().mark_disconnected() + except Exception: + pass + flash("CTP 配置已保存,请在持仓监控页重连 CTP") elif action == "nav": items = {k: request.form.get(f"nav_{k}") == "on" for k in NAV_TOGGLES} save_nav_items(set_setting, items) @@ -1736,11 +1750,15 @@ def settings(): ctp_st = ctp_status(get_trading_mode(get_setting)) except Exception: pass + from ctp_settings import get_ctp_settings_for_ui + return render_template( "settings.html", webhook=webhook, username=username, quote_label=get_quote_source_label(ctp_connected=bool(ctp_st.get("connected"))), + ctp_status=ctp_st, + ctp_cfg=get_ctp_settings_for_ui(), 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_settings.py b/ctp_settings.py new file mode 100644 index 0000000..3be510c --- /dev/null +++ b/ctp_settings.py @@ -0,0 +1,108 @@ +"""CTP / SimNow 配置:系统设置优先,.env 作兜底。""" +from __future__ import annotations + +import os +from typing import Any, Callable + +# (db_key, env_key, vnpy字段名, 默认值) +SIMNOW_FIELDS: tuple[tuple[str, str, str, str], ...] = ( + ("simnow_user", "SIMNOW_USER", "用户名", ""), + ("simnow_password", "SIMNOW_PASSWORD", "密码", ""), + ("simnow_broker_id", "SIMNOW_BROKER_ID", "经纪商代码", "9999"), + ("simnow_td_address", "SIMNOW_TD_ADDRESS", "交易服务器", "tcp://180.168.146.187:10201"), + ("simnow_md_address", "SIMNOW_MD_ADDRESS", "行情服务器", "tcp://180.168.146.187:10211"), + ("simnow_app_id", "SIMNOW_APP_ID", "产品名称", "simnow_client_test"), + ("simnow_auth_code", "SIMNOW_AUTH_CODE", "授权编码", "0000000000000000"), + ("simnow_env", "SIMNOW_ENV", "柜台环境", "实盘"), +) + +LIVE_FIELDS: tuple[tuple[str, str, str, str], ...] = ( + ("ctp_live_user", "CTP_LIVE_USER", "用户名", ""), + ("ctp_live_password", "CTP_LIVE_PASSWORD", "密码", ""), + ("ctp_live_broker_id", "CTP_LIVE_BROKER_ID", "经纪商代码", ""), + ("ctp_live_td_address", "CTP_LIVE_TD_ADDRESS", "交易服务器", ""), + ("ctp_live_md_address", "CTP_LIVE_MD_ADDRESS", "行情服务器", ""), + ("ctp_live_app_id", "CTP_LIVE_APP_ID", "产品名称", ""), + ("ctp_live_auth_code", "CTP_LIVE_AUTH_CODE", "授权编码", ""), + ("ctp_live_env", "CTP_LIVE_ENV", "柜台环境", "实盘"), +) + +PASSWORD_DB_KEYS = frozenset({"simnow_password", "ctp_live_password"}) + + +def _get_db_setting(key: str, default: str = "") -> str: + from fee_specs import get_setting + + return (get_setting(key, default) or default).strip() + + +def resolve_ctp_value(db_key: str, env_key: str, default: str = "") -> str: + v = _get_db_setting(db_key, "") + if v: + return v + return (os.getenv(env_key) or default).strip() + + +def _build_setting_dict(fields: tuple[tuple[str, str, str, str], ...]) -> dict[str, str]: + out: dict[str, str] = {} + for db_key, env_key, vnpy_key, default in fields: + out[vnpy_key] = resolve_ctp_value(db_key, env_key, default) + return out + + +def simnow_setting_dict() -> dict[str, str]: + return _build_setting_dict(SIMNOW_FIELDS) + + +def live_setting_dict() -> dict[str, str]: + return _build_setting_dict(LIVE_FIELDS) + + +def seed_ctp_settings_from_env(set_setting: Callable[[str, str], None]) -> None: + """首次启动:将 .env 中已有 CTP 配置写入 settings 表。""" + for db_key, env_key, _, _ in (*SIMNOW_FIELDS, *LIVE_FIELDS): + if _get_db_setting(db_key, ""): + continue + env_val = (os.getenv(env_key) or "").strip() + if env_val: + set_setting(db_key, env_val) + + +def get_ctp_settings_for_ui() -> dict[str, Any]: + ui: dict[str, Any] = {} + for db_key, env_key, _, default in SIMNOW_FIELDS: + ui[db_key] = resolve_ctp_value(db_key, env_key, default) + if db_key in PASSWORD_DB_KEYS: + ui[f"{db_key}_set"] = bool(ui[db_key]) + ui[db_key] = "" + for db_key, env_key, _, default in LIVE_FIELDS: + ui[db_key] = resolve_ctp_value(db_key, env_key, default) + if db_key in PASSWORD_DB_KEYS: + ui[f"{db_key}_set"] = bool(ui[db_key]) + ui[db_key] = "" + return ui + + +def save_ctp_settings_from_form( + form: Any, + set_setting: Callable[[str, str], None], +) -> None: + """保存 CTP 配置;密码留空表示不修改。""" + for db_key, _, _, default in SIMNOW_FIELDS: + if db_key in PASSWORD_DB_KEYS: + val = (form.get(db_key) or "").strip() + if val: + set_setting(db_key, val) + continue + val = (form.get(db_key) or "").strip() + set_setting(db_key, val or default) + + for db_key, _, _, default in LIVE_FIELDS: + if db_key in PASSWORD_DB_KEYS: + val = (form.get(db_key) or "").strip() + if val: + set_setting(db_key, val) + continue + val = (form.get(db_key) or "").strip() + if default or val: + set_setting(db_key, val or default) diff --git a/docs/SIMNOW.md b/docs/SIMNOW.md index 5afc6dc..1bcbdcf 100644 --- a/docs/SIMNOW.md +++ b/docs/SIMNOW.md @@ -133,7 +133,7 @@ SimNow 提供多种仿真环境,**IP 与端口会随官网公告调整**,部 tcp://IP:端口 ``` -修改 `.env` 后需重启应用: +修改 `.env` 后需重启应用;也可在 **系统设置 → CTP 连接** 中维护(优先于 `.env`)。 ```bash pm2 restart qihuo diff --git a/scripts/test_simnow.py b/scripts/test_simnow.py index e9112d5..1400fbd 100644 --- a/scripts/test_simnow.py +++ b/scripts/test_simnow.py @@ -33,18 +33,21 @@ def _probe(host_port: str) -> str: def main() -> int: - user = os.getenv("SIMNOW_USER", "") - td = os.getenv("SIMNOW_TD_ADDRESS", "tcp://180.168.146.187:10201") - md = os.getenv("SIMNOW_MD_ADDRESS", "tcp://180.168.146.187:10211") - env = os.getenv("SIMNOW_ENV", "实盘") + from ctp_settings import resolve_ctp_value - print("=== SimNow 配置 ===") + user = resolve_ctp_value("simnow_user", "SIMNOW_USER") + pwd = resolve_ctp_value("simnow_password", "SIMNOW_PASSWORD") + td = resolve_ctp_value("simnow_td_address", "SIMNOW_TD_ADDRESS", "tcp://180.168.146.187:10201") + md = resolve_ctp_value("simnow_md_address", "SIMNOW_MD_ADDRESS", "tcp://180.168.146.187:10211") + env = resolve_ctp_value("simnow_env", "SIMNOW_ENV", "实盘") + + print("=== SimNow 配置(系统设置优先)===") print(f"locale = {ensure_process_locale()}") missing = missing_ctp_locales() if missing: print(f"警告: 缺少 CTP 所需 locale: {', '.join(missing)}") print(f"SIMNOW_USER = {user or '(未设置)'}") - print(f"SIMNOW_PASSWORD = {'*' * 8 if os.getenv('SIMNOW_PASSWORD') else '(未设置)'}") + print(f"SIMNOW_PASSWORD = {'*' * 8 if pwd else '(未设置)'}") print(f"SIMNOW_TD = {td}") print(f"SIMNOW_MD = {md}") print(f"SIMNOW_ENV = {env}") @@ -54,8 +57,8 @@ def main() -> int: print(f"MD {md} -> {_probe(md)}") print() - if not user or not os.getenv("SIMNOW_PASSWORD"): - print("错误:请在 .env 填写 SIMNOW_USER / SIMNOW_PASSWORD") + if not user or not pwd: + print("错误:请在系统设置或 .env 填写 SimNow 投资者代码与密码") return 1 print("=== CTP 登录测试 ===") diff --git a/templates/settings.html b/templates/settings.html index 6d8a0bb..fd27add 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -14,8 +14,14 @@ .settings-tips{flex:1;display:flex;flex-direction:column;justify-content:center;gap:.5rem;margin:0;padding:0;list-style:none;font-size:.85rem;color:var(--text-muted);line-height:1.55} .settings-tips li{padding-left:1rem;position:relative} .settings-tips li::before{content:"";position:absolute;left:0;top:.55em;width:5px;height:5px;border-radius:50%;background:var(--accent)} +.settings-ctp-grid{display:grid;grid-template-columns:1fr 1fr;gap:.65rem .75rem} +.settings-ctp-grid .field-full{grid-column:1/-1} +.settings-ctp-section{margin-bottom:1rem;padding-bottom:1rem;border-bottom:1px solid var(--border)} +.settings-ctp-section:last-of-type{border-bottom:none;margin-bottom:0;padding-bottom:0} +.settings-ctp-section h3{font-size:.9rem;margin:0 0 .65rem;color:var(--text-title)} +.settings-ctp-status{font-size:.82rem;color:var(--text-muted);margin-top:.75rem;line-height:1.5} @media(max-width:900px){ - .settings-password-form{grid-template-columns:1fr} + .settings-ctp-grid{grid-template-columns:1fr} } {% endblock %} @@ -78,12 +84,120 @@

- 保证金上限用于开仓校验与品种最大手数估算(默认 30%)。移动保本:达 1R 后止损移至开仓价 ± N 跳(玉米 N=2 即 +2 点,棉花 N=2 即 +10 点);达 2R 移至 1R,依次类推。在 .env 配置 SIMNOW_USER,于「持仓监控」连接 CTP。 + 保证金上限用于开仓校验与品种最大手数估算(默认 30%)。移动保本:达 1R 后止损移至开仓价 ± N 跳。CTP 账号与前置在下方「CTP 连接」中配置。

+
+

CTP 连接

+
+ +

+ 投资者代码、密码、前置地址在此维护(优先于 .env)。保存后请在持仓监控页点击「重连 CTP」。 + {% if ctp_status.connected %} + 已连接 + {% elif ctp_status.connecting %} + 连接中 + {% elif ctp_status.last_error %} + {{ ctp_status.last_error }} + {% endif %} +

+ +
+

SimNow 模拟盘

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+

期货公司实盘(后期)

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +

+ 官方第一套:180.168.146.187:10201/10211; + 7×24:182.254.243.31:40001/40011(新账号可能需满 3 个交易日)。 + 详见 docs/SIMNOW.md。 +

+
+
+

行情说明

diff --git a/vnpy_bridge.py b/vnpy_bridge.py index 8d0592a..3695995 100644 --- a/vnpy_bridge.py +++ b/vnpy_bridge.py @@ -12,6 +12,7 @@ from locale_fix import ensure_process_locale ensure_process_locale() +from ctp_settings import live_setting_dict, simnow_setting_dict from ctp_symbol import ths_to_vnpy_symbol, to_vnpy_exchange from contract_specs import get_contract_spec @@ -43,35 +44,13 @@ _bridge: Optional["CtpBridge"] = None _bridge_lock = threading.Lock() -def _env(key: str, default: str = "") -> str: - return (os.getenv(key) or default).strip() - - def _simnow_setting() -> dict[str, str]: - """SimNow 仿真前置(可在 .env 覆盖)。看穿式前置需「柜台环境=实盘」。""" - return { - "用户名": _env("SIMNOW_USER"), - "密码": _env("SIMNOW_PASSWORD"), - "经纪商代码": _env("SIMNOW_BROKER_ID", "9999"), - "交易服务器": _env("SIMNOW_TD_ADDRESS", "tcp://180.168.146.187:10201"), - "行情服务器": _env("SIMNOW_MD_ADDRESS", "tcp://180.168.146.187:10211"), - "产品名称": _env("SIMNOW_APP_ID", "simnow_client_test"), - "授权编码": _env("SIMNOW_AUTH_CODE", "0000000000000000"), - "柜台环境": _env("SIMNOW_ENV", "实盘"), - } + """SimNow 仿真前置(系统设置优先,.env 兜底)。""" + return simnow_setting_dict() def _live_setting() -> dict[str, str]: - return { - "用户名": _env("CTP_LIVE_USER"), - "密码": _env("CTP_LIVE_PASSWORD"), - "经纪商代码": _env("CTP_LIVE_BROKER_ID"), - "交易服务器": _env("CTP_LIVE_TD_ADDRESS"), - "行情服务器": _env("CTP_LIVE_MD_ADDRESS"), - "产品名称": _env("CTP_LIVE_APP_ID"), - "授权编码": _env("CTP_LIVE_AUTH_CODE"), - "柜台环境": _env("CTP_LIVE_ENV", "实盘"), - } + return live_setting_dict() def _setting_for_mode(mode: str) -> dict[str, str]: @@ -206,12 +185,18 @@ class CtpBridge: self._connected_mode = None time.sleep(0.6) - def _wait_connected(self, mode: str) -> bool: + def _login_rejected(self, ctp_logs: list[str]) -> bool: + return any("登录失败" in m or "不合法的登录" in m for m in ctp_logs) + + def _wait_connected(self, mode: str, ctp_logs: list[str] | None = None) -> bool: """等待账户回报或交易通道登录成功。""" if not self._engine: return False + logs = ctp_logs or [] loops = max(1, int(CONNECT_WAIT_SEC / CONNECT_POLL_INTERVAL_SEC)) for _ in range(loops): + if self._login_rejected(logs): + return False try: if self._engine.get_all_accounts(): return True @@ -302,7 +287,7 @@ class CtpBridge: "并在服务器执行 nc -zv 验证出网。" ) self._engine.connect(setting, GATEWAY_NAME) - if self._wait_connected(mode): + if self._wait_connected(mode, ctp_logs): self._connected_mode = mode self._last_error = "" logger.info("CTP 已连接 [%s] td_login=%s accounts=%s",