diff --git a/CHANGELOG.md b/CHANGELOG.md index 014a210..f528177 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ 本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。 +## [1.19.7] — 2026-07-07 + +**新增「服务器设置」页面:web UI 上测速 + 切换通达信服务器** —— 解决"有些用户获取到的 IP 能连通、有些不能"的问题。不同地区/运营商对通达信各服务器连通性不同,之前用户只能碰运气或手动改 config.json。现在在 web UI 上新增第六个页面「服务器设置」,列出全部 50+ 候选服务器、一键并发测速、点选切换——切换后立即生效(热重连),无需重启服务。 + +### 新增 + +- **`AsyncTdxClient.reconnect_to(host)`**(`src/easy_tdx/client.py`)—— 热切换 host 的核心方法:复用 `_execute_lock` 保证切换期间无并发请求撞半开连接,关旧连接→换 host→建新连接→重启心跳。切换失败抛异常(client 断开,路由层捕获返回友好提示)。 +- **服务器设置路由**(`src/easy_tdx/web/routers/server.py`)—— 3 个端点: + - `GET /api/v1/server/hosts`:列出候选 host + 当前 host(不测速,首屏秒开) + - `POST /api/v1/server/test`:并发 ping 测速,返回延迟(ms)和可达性,按延迟排序 + - `POST /api/v1/server/switch`:切换到指定 host(先 reconnect 成功再 save_best_host,避免连接失败污染 config) +- **服务器设置页面**(`web-ui/src/views/ServerSettingsView.vue`)—— 左侧当前 host + 测速按钮,右侧 host 列表表格(IP/延迟颜色编码/状态徽章/使用按钮)。延迟 <100ms 绿色、<300ms 蓝色、≥300ms 红色、不可达灰色。 +- **导航入口**:顶部导航栏新增「服务器设置」(第 6 个页面)。 + +### 设计决策 + +- **不自动测速**:页面加载只列 host,点按钮才测速(50+ host 全 ping 要几秒,自动测速会卡首屏)。 +- **切换顺序**:先 `reconnect_to` 成功 → 再 `save_best_host` 持久化(v1.19.4 host 污染 bug 的教训)。 +- **host 校验**:只允许切换到候选列表里的 IP,防止任意地址注入。 + ## [1.19.6] — 2026-07-07 **修复 EXE 丢失所有第三方依赖(pandas/numpy/uvicorn 等)** —— v1.19.5 的 EXE 只有 11MB(正常 44MB),双击报 `ModuleNotFoundError: No module named 'pandas'`。根因:`release.yml` 的步骤顺序是先 `pip install -e ".[web,packaging]"` 再 `Build frontend`,但 `pyproject.toml` 的 `force-include` 要求 `web-ui/dist` 在 `pip install` 时就存在——install 阶段 dist 不存在导致 editable install 静默降级,PyInstaller 收集不到第三方包。修复:调换 `release.yml` 步骤顺序,先 `npm run build` 再 `pip install`。 diff --git a/pyproject.toml b/pyproject.toml index 1d4f556..09b5a64 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "easy-tdx" -version = "1.19.6" +version = "1.19.7" description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步" readme = "README.md" requires-python = ">=3.10" diff --git a/src/easy_tdx/client.py b/src/easy_tdx/client.py index df0da1b..2b18cf8 100644 --- a/src/easy_tdx/client.py +++ b/src/easy_tdx/client.py @@ -880,6 +880,22 @@ class AsyncTdxClient(AsyncHeartbeatMixin): await self._stop_heartbeat() await self._conn.close() + async def reconnect_to(self, host: str) -> None: + """热切换到新 host:关旧连接 → 换 host → 建新连接。 + + 用于 web UI 的"服务器设置"页面——用户点选一个 host 后,无需重启 + 服务即可切换。复用 ``_execute_lock`` 保证切换期间没有并发行情请求 + 撞到半开的连接。切换失败抛异常(旧连接已 close,client 处于断开 + 状态,调用方应捕获并提示用户选别的 host)。 + """ + async with self._execute_lock: + await self._stop_heartbeat() + await self._conn.close() + self._host = host + self._conn = AsyncTdxConnection(host, self._port, self._timeout) + await self._conn.connect() + self._start_heartbeat() + async def __aenter__(self) -> "AsyncTdxClient": await self.connect() return self diff --git a/src/easy_tdx/web/app.py b/src/easy_tdx/web/app.py index 0e2c2fd..8f3e8ed 100644 --- a/src/easy_tdx/web/app.py +++ b/src/easy_tdx/web/app.py @@ -211,6 +211,7 @@ def _create_app( from easy_tdx.web.routers.mac_quotes import router as mac_quotes_router from easy_tdx.web.routers.market import router as market_router from easy_tdx.web.routers.realtime import router as realtime_router + from easy_tdx.web.routers.server import router as server_router from easy_tdx.web.routers.sina import router as sina_router from easy_tdx.web.routers.strategies import router as strategies_router @@ -236,6 +237,8 @@ def _create_app( app.include_router(backtest_router, prefix="/api/v1") # 策略库路由(SQLite 持久化,纯数据 CRUD) app.include_router(strategies_router, prefix="/api/v1") + # 服务器设置路由(列出/测速/切换 TDX host) + app.include_router(server_router, prefix="/api/v1") # --- 前端 dist 托管(生产/打包态同源服务,开发态可缺省) --- # 必须在所有 API 路由注册之后:StaticFiles(html=True) 挂在 "/" 会吞掉 diff --git a/src/easy_tdx/web/routers/server.py b/src/easy_tdx/web/routers/server.py new file mode 100644 index 0000000..aadda1f --- /dev/null +++ b/src/easy_tdx/web/routers/server.py @@ -0,0 +1,162 @@ +"""服务器设置路由:列出/测速/切换标准 TDX 行情服务器。 + +让用户在 web UI 上看到候选 host 列表、一键测速、点选切换——解决"有些 IP +能连通有些不能"的问题(不同地区/运营商对通达信各服务器连通性不同)。 +切换是热重连(``reconnect_to``),无需重启服务。 +""" + +from __future__ import annotations + +import asyncio + +from fastapi import APIRouter, Request +from pydantic import BaseModel + +from easy_tdx.config import get_best_host, get_known_hosts, get_port, save_best_host +from easy_tdx.transport.sync import ping_all + +router = APIRouter(tags=["server"]) + + +# --------------------------------------------------------------------------- # +# Schemas +# --------------------------------------------------------------------------- # + + +class HostInfo(BaseModel): + """单个 host 的状态信息。""" + + host: str + latency_ms: int | None = None # None = 未测速或不可达 + reachable: bool = False + is_current: bool = False + + +class HostListResponse(BaseModel): + """GET /server/hosts 的响应。""" + + hosts: list[HostInfo] + current_host: str + total: int + + +class ServerTestRequest(BaseModel): + """POST /server/test 的请求。""" + + hosts: list[str] | None = None # None = 测全部候选 + timeout: float = 5.0 + + +class ServerSwitchRequest(BaseModel): + """POST /server/switch 的请求。""" + + host: str + + +class SwitchResponse(BaseModel): + """POST /server/switch 的响应。""" + + ok: bool + host: str + message: str + + +# --------------------------------------------------------------------------- # +# Routes +# --------------------------------------------------------------------------- # + + +@router.get("/server/hosts", response_model=HostListResponse) +async def list_hosts(request: Request) -> HostListResponse: + """列出所有候选 host + 当前正在使用的 host。 + + 不做测速(避免 50+ host 全 ping 让首屏卡几秒)。前端点"测试全部"按钮 + 后调 ``POST /server/test`` 获取延迟。 + """ + candidates = get_known_hosts() + current = _get_current_host(request) + + host_infos = [HostInfo(host=h, is_current=(h == current)) for h in candidates] + return HostListResponse(hosts=host_infos, current_host=current, total=len(host_infos)) + + +@router.post("/server/test", response_model=list[HostInfo]) +async def test_hosts(req: ServerTestRequest, request: Request) -> list[HostInfo]: + """并发 ping 测试 host 列表,返回延迟和可达性。 + + 用 ``asyncio.to_thread`` 包装同步的 ``ping_all``(它内部用 + ThreadPoolExecutor 并发),避免阻塞事件循环。 + """ + hosts = req.hosts if req.hosts else get_known_hosts() + port = get_port() + current = _get_current_host(request) + + # ping_all 是同步阻塞函数,放到线程池跑 + ranked = await asyncio.to_thread(ping_all, hosts, port, req.timeout) + + # ranked 是 [(host, latency_sec)],已按延迟升序排列,只含可达的 + reachable_map = {h: round(s * 1000) for h, s in ranked} + + # 按原始 hosts 顺序返回(保持列表稳定),但把可达的排前面 + result = [] + for h in hosts: + latency_ms = reachable_map.get(h) + result.append( + HostInfo( + host=h, + latency_ms=latency_ms, + reachable=latency_ms is not None, + is_current=(h == current), + ) + ) + + # 可达的排前面(按延迟升序),不可达的排后面 + result.sort(key=lambda x: (x.reachable is False, x.latency_ms or 999999)) + return result + + +@router.post("/server/switch", response_model=SwitchResponse) +async def switch_host(req: ServerSwitchRequest, request: Request) -> SwitchResponse: + """切换到指定 host(热重连,无需重启服务)。 + + 顺序:先 reconnect_to 成功 → 再 save_best_host 持久化。 + 如果 reconnect 失败,不 save(避免污染 config,用户可再选别的)。 + """ + candidates = get_known_hosts() + if req.host not in candidates: + return SwitchResponse( + ok=False, + host=req.host, + message=f"主机 {req.host} 不在候选列表里,无法切换", + ) + + client = request.app.state.tdx_client + if client is None: + return SwitchResponse(ok=False, host=req.host, message="TDX 客户端未初始化") + + try: + await client.reconnect_to(req.host) + except Exception as e: + return SwitchResponse( + ok=False, + host=req.host, + message=f"连接 {req.host} 失败:{e}。请选其他服务器。", + ) + + # 连接成功后才持久化 + save_best_host(req.host) + return SwitchResponse(ok=True, host=req.host, message=f"已切换到 {req.host}") + + +# --------------------------------------------------------------------------- # +# Helpers +# --------------------------------------------------------------------------- # + + +def _get_current_host(request: Request) -> str: + """获取当前 TDX 客户端实际连接的 host。""" + client = getattr(request.app.state, "tdx_client", None) + if client is not None: + # AsyncTdxClient._host 是实际连接的 host(reconnect_to 会更新它) + return getattr(client, "_host", get_best_host()) + return get_best_host() diff --git a/web-ui/src/App.vue b/web-ui/src/App.vue index cc63149..8ab88a2 100644 --- a/web-ui/src/App.vue +++ b/web-ui/src/App.vue @@ -12,6 +12,7 @@ 参数寻优 结果对比 策略库 + 服务器设置
diff --git a/web-ui/src/api.ts b/web-ui/src/api.ts index ef90599..08ba6ea 100644 --- a/web-ui/src/api.ts +++ b/web-ui/src/api.ts @@ -14,6 +14,9 @@ import type { SavedStrategy, SavedStrategyCreate, SavedStrategyListResponse, + ServerHostInfo, + ServerHostListResponse, + ServerSwitchResult, StrategiesResponse, TaskListResponse, TaskState, @@ -264,3 +267,34 @@ export async function deleteSavedStrategy(id: string): Promise { const resp = await fetch(`${BASE}/strategies/${id}`, { method: 'DELETE' }) if (!resp.ok) await throwError(resp) } + +// ── 服务器设置 ────────────────────────────────────────────────────────────── + +/** 列出所有候选通达信服务器 + 当前使用的 host(不含延迟,需点测速)。 */ +export async function fetchServerHosts(): Promise { + const resp = await fetch(`${BASE}/server/hosts`) + if (!resp.ok) await throwError(resp) + return (await resp.json()) as ServerHostListResponse +} + +/** 并发测速全部(或指定)host,返回延迟和可达性。 */ +export async function testServerHosts(hosts?: string[]): Promise { + const resp = await fetch(`${BASE}/server/test`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ hosts: hosts ?? null }), + }) + if (!resp.ok) await throwError(resp) + return (await resp.json()) as ServerHostInfo[] +} + +/** 切换到指定 host(热重连,无需重启服务)。 */ +export async function switchServerHost(host: string): Promise { + const resp = await fetch(`${BASE}/server/switch`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ host }), + }) + if (!resp.ok) await throwError(resp) + return (await resp.json()) as ServerSwitchResult +} diff --git a/web-ui/src/router.ts b/web-ui/src/router.ts index 930e854..c2a1575 100644 --- a/web-ui/src/router.ts +++ b/web-ui/src/router.ts @@ -4,15 +4,17 @@ import BacktestView from './views/BacktestView.vue' import CompareView from './views/CompareView.vue' import OptimizeView from './views/OptimizeView.vue' import PortfolioView from './views/PortfolioView.vue' +import ServerSettingsView from './views/ServerSettingsView.vue' import StrategiesView from './views/StrategiesView.vue' -// 单标的回测(/)+ 组合回测(/portfolio)+ 参数寻优(/optimize)+ 结果对比(/compare)+ 策略库(/strategies)。 +// 单标的回测(/)+ 组合回测(/portfolio)+ 参数寻优(/optimize)+ 结果对比(/compare)+ 策略库(/strategies)+ 服务器设置(/settings)。 const routes = [ { path: '/', name: 'backtest', component: BacktestView }, { path: '/portfolio', name: 'portfolio', component: PortfolioView }, { path: '/optimize', name: 'optimize', component: OptimizeView }, { path: '/compare', name: 'compare', component: CompareView }, { path: '/strategies', name: 'strategies', component: StrategiesView }, + { path: '/settings', name: 'settings', component: ServerSettingsView }, ] export const router = createRouter({ diff --git a/web-ui/src/types.ts b/web-ui/src/types.ts index f525b50..f1a5ea2 100644 --- a/web-ui/src/types.ts +++ b/web-ui/src/types.ts @@ -330,3 +330,28 @@ export interface MultiStrategyBacktestRequest { slippage?: number execution?: ExecutionMode } + +// ── 服务器设置(GET /api/v1/server/hosts 等) ──────────────────────────────── + +/** 单个通达信服务器的状态信息。 */ +export interface ServerHostInfo { + host: string + /** 延迟(毫秒)。null = 未测速或不可达。 */ + latency_ms: number | null + reachable: boolean + is_current: boolean +} + +/** GET /server/hosts 的响应。 */ +export interface ServerHostListResponse { + hosts: ServerHostInfo[] + current_host: string + total: number +} + +/** POST /server/switch 的响应。 */ +export interface ServerSwitchResult { + ok: boolean + host: string + message: string +} diff --git a/web-ui/src/views/ServerSettingsView.vue b/web-ui/src/views/ServerSettingsView.vue new file mode 100644 index 0000000..6247805 --- /dev/null +++ b/web-ui/src/views/ServerSettingsView.vue @@ -0,0 +1,322 @@ + + + + +