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 连接」中配置。