feat: CTP/SimNow 配置迁入系统设置,登录失败即时报错

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-25 16:46:01 +08:00
parent 72361233a0
commit 5a6c89c662
7 changed files with 267 additions and 39 deletions
+1 -1
View File
@@ -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
+18
View File
@@ -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"),
+108
View File
@@ -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)
+1 -1
View File
@@ -133,7 +133,7 @@ SimNow 提供多种仿真环境,**IP 与端口会随官网公告调整**,部
tcp://IP:端口
```
修改 `.env` 后需重启应用
修改 `.env` 后需重启应用;也可在 **系统设置 → CTP 连接** 中维护(优先于 `.env`)。
```bash
pm2 restart qihuo
+11 -8
View File
@@ -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 登录测试 ===")
+116 -2
View File
@@ -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}
}
</style>
{% endblock %}
@@ -78,12 +84,120 @@
</div>
<button type="submit" class="btn-primary" style="margin-top:.75rem">保存交易设置</button>
<p class="hint" style="margin-top:.75rem;margin-bottom:0">
保证金上限用于开仓校验与品种最大手数估算(默认 30%)。<strong>移动保本</strong>:达 1R 后止损移至开仓价 ± N 跳(玉米 N=2 即 +2 点,棉花 N=2 即 +10 点);达 2R 移至 1R,依次类推。在 <code>.env</code> 配置 <code>SIMNOW_USER</code>,于「持仓监控」连接 CTP
保证金上限用于开仓校验与品种最大手数估算(默认 30%)。<strong>移动保本</strong>:达 1R 后止损移至开仓价 ± N 跳。CTP 账号与前置在下方「CTP 连接」中配置
</p>
</form>
</div>
</div>
<div class="card">
<h2>CTP 连接</h2>
<form action="{{ url_for('settings') }}" method="post">
<input type="hidden" name="action" value="ctp">
<p class="hint" style="margin-bottom:.85rem">
投资者代码、密码、前置地址在此维护(优先于 <code>.env</code>)。保存后请在持仓监控页点击「重连 CTP」。
{% if ctp_status.connected %}
<span class="badge profit" style="margin-left:.35rem">已连接</span>
{% elif ctp_status.connecting %}
<span class="badge planned" style="margin-left:.35rem">连接中</span>
{% elif ctp_status.last_error %}
<span class="text-loss" style="display:block;margin-top:.35rem">{{ ctp_status.last_error }}</span>
{% endif %}
</p>
<div class="settings-ctp-section">
<h3>SimNow 模拟盘</h3>
<div class="settings-ctp-grid">
<div class="field">
<label>投资者代码</label>
<input name="simnow_user" value="{{ ctp_cfg.simnow_user }}" placeholder="非手机号">
</div>
<div class="field">
<label>交易密码</label>
<input name="simnow_password" type="password" autocomplete="new-password"
placeholder="{% if ctp_cfg.simnow_password_set %}已设置,留空不修改{% else %}SimNow 密码{% endif %}">
</div>
<div class="field">
<label>经纪商代码</label>
<input name="simnow_broker_id" value="{{ ctp_cfg.simnow_broker_id }}">
</div>
<div class="field">
<label>柜台环境</label>
<select name="simnow_env">
<option value="实盘" {% if ctp_cfg.simnow_env == '实盘' %}selected{% endif %}>实盘(看穿式,推荐)</option>
<option value="测试" {% if ctp_cfg.simnow_env == '测试' %}selected{% endif %}>测试</option>
</select>
</div>
<div class="field field-full">
<label>交易前置</label>
<input name="simnow_td_address" value="{{ ctp_cfg.simnow_td_address }}" placeholder="tcp://180.168.146.187:10201">
</div>
<div class="field field-full">
<label>行情前置</label>
<input name="simnow_md_address" value="{{ ctp_cfg.simnow_md_address }}" placeholder="tcp://180.168.146.187:10211">
</div>
<div class="field">
<label>AppID</label>
<input name="simnow_app_id" value="{{ ctp_cfg.simnow_app_id }}">
</div>
<div class="field">
<label>授权编码</label>
<input name="simnow_auth_code" value="{{ ctp_cfg.simnow_auth_code }}">
</div>
</div>
</div>
<div class="settings-ctp-section">
<h3>期货公司实盘(后期)</h3>
<div class="settings-ctp-grid">
<div class="field">
<label>投资者代码</label>
<input name="ctp_live_user" value="{{ ctp_cfg.ctp_live_user }}">
</div>
<div class="field">
<label>交易密码</label>
<input name="ctp_live_password" type="password" autocomplete="new-password"
placeholder="{% if ctp_cfg.ctp_live_password_set %}已设置,留空不修改{% else %}实盘密码{% endif %}">
</div>
<div class="field">
<label>经纪商代码</label>
<input name="ctp_live_broker_id" value="{{ ctp_cfg.ctp_live_broker_id }}">
</div>
<div class="field">
<label>柜台环境</label>
<select name="ctp_live_env">
<option value="实盘" {% if ctp_cfg.ctp_live_env == '实盘' %}selected{% endif %}>实盘</option>
<option value="测试" {% if ctp_cfg.ctp_live_env == '测试' %}selected{% endif %}>测试</option>
</select>
</div>
<div class="field field-full">
<label>交易前置</label>
<input name="ctp_live_td_address" value="{{ ctp_cfg.ctp_live_td_address }}" placeholder="tcp://...">
</div>
<div class="field field-full">
<label>行情前置</label>
<input name="ctp_live_md_address" value="{{ ctp_cfg.ctp_live_md_address }}" placeholder="tcp://...">
</div>
<div class="field">
<label>AppID</label>
<input name="ctp_live_app_id" value="{{ ctp_cfg.ctp_live_app_id }}">
</div>
<div class="field">
<label>授权编码</label>
<input name="ctp_live_auth_code" value="{{ ctp_cfg.ctp_live_auth_code }}">
</div>
</div>
</div>
<button type="submit" class="btn-primary">保存 CTP 配置</button>
<p class="settings-ctp-status">
官方第一套:<code>180.168.146.187:10201/10211</code>
7×24<code>182.254.243.31:40001/40011</code>(新账号可能需满 3 个交易日)。
详见 <code>docs/SIMNOW.md</code>
</p>
</form>
</div>
<div class="split-grid">
<div class="card">
<h2>行情说明</h2>
+12 -27
View File
@@ -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",