Files
easy_tdx_max/src/easy_tdx/web/app.py
T
GitHub e58de789ab feat(ws): /ws/realtime 接通 RealtimeDataFeed — 按需轮询 hub + fan-out + 冒烟脚本
- web/realtime_hub.py:RealtimeStreamHub——订阅集合变化按需启停 RealtimeDataFeed,
  无人订阅完全停止轮询(对齐 QuoteStreamer 节能);EventBus → 每连接队列
  fan-out(丢最旧保最新);去重标的上限 80;lifespan 挂载/关闭
- routers/realtime.py 重写:连接即订阅、断开退订、subscribe/unsubscribe 控制帧、
  30s ping 心跳;单一写者泵模型(全部出站帧经队列串行,防并发 send 交错)
- 修复 RealtimeDataFeed stop-before-start 竞态(_stop_requested 标志),
  补 2 个回归用例;hub 单测 10 例(并发订阅/退订竞态、跨标的串扰、背压、
  TestClient 端到端)
- 前端接入选文档方案(api_reference.md 协议+重连/心跳骨架;README 撤「未联动」):
  SSE 已覆盖看板/自选实时刷新,WS 定位按需单标的 tick,双通道冗余无必要
- scripts/ws_smoke.py:手动冒烟(mock 模式实测 tick 帧与动态订阅确认可见)
2026-09-01 23:21:42 +08:00

389 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""FastAPI application factory and lifespan management."""
from __future__ import annotations
import logging
import os
import sys
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from easy_tdx.web.errors import register_exception_handlers
logger = logging.getLogger(__name__)
def _resolve_web_dist_dir() -> Path | None:
"""定位前端构建产物目录(Vite build 输出的 ``web-ui/dist``)。
依次探测四处,命中即返回,全部缺失时返回 ``None``(仅 API 可用,
前端页面 404):
1. ``EASY_TDX_WEB_DIST`` 环境变量——部署/调试时显式指定。
2. PyInstaller 运行态:``sys._MEIPASS / "web_dist"``——单 EXE 解压
后的临时目录(``--onefile`` 模式)。开发态无 ``_MEIPASS`` 属性,
此分支自动跳过。
3. 开发态:仓库根目录的 ``web-ui/dist``——支持 ``pip install -e .``
后直接 ``easy-tdx serve`` 调试,无需打包。
4. PyPI wheel 安装态:包内 ``easy_tdx/web/dist``——hatchling 把
编译好的 dist 作为数据文件打进 wheelv1.19.5 起含),让
``pip install easy-tdx[web]`` 后开箱即用 web UI。
"""
env_dir = os.environ.get("EASY_TDX_WEB_DIST")
if env_dir:
p = Path(env_dir)
if p.is_dir():
return p
# PyInstaller --onefile 解压目录(frozen 运行态)
meipass = getattr(sys, "_MEIPASS", None)
if meipass is not None:
p = Path(meipass) / "web_dist"
if p.is_dir():
return p
# 开发态:从 src/easy_tdx/web/app.py 回溯到仓库根的 web-ui/dist
repo_root = Path(__file__).resolve().parents[3]
p = repo_root / "web-ui" / "dist"
if p.is_dir():
return p
# PyPI 安装态:包内的 web/distwheel 打包时 force-include 进来)
pkg_dist = Path(__file__).resolve().parent / "dist"
if pkg_dist.is_dir():
return pkg_dist
return None
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""管理 TDX 连接生命周期:启动时连接,关闭时断开。
E2E mock 模式(``EASY_TDX_E2E_MOCK=1``,见 :mod:`easy_tdx.web.e2e_mock`):
全部行情连接替换为合成数据客户端(不连真实服务器、不受交易时段限制),
回测 / 自选 / 策略库等纯计算路径保持真实,供 Playwright E2E 使用。
"""
from easy_tdx.web.e2e_mock import is_e2e_mock_enabled, log_mock_banner
mock_mode = is_e2e_mock_enabled()
# --- 标准 TDX 客户端 ---
host = app.state.tdx_host
port = app.state.tdx_port
timeout = app.state.tdx_timeout
client: Any
if mock_mode:
from easy_tdx.web.e2e_mock import MockTdxClient
log_mock_banner()
client = MockTdxClient()
else:
from easy_tdx.client import AsyncTdxClient
client = AsyncTdxClient(host=host, port=port, timeout=timeout)
try:
await client.connect()
logger.info("TDX client connected to %s:%s", host, port)
except Exception:
logger.warning("TDX client connection failed — endpoints will return 503")
app.state.tdx_client = client
# --- 实时行情 SSE 推送器(共享轮询 + fan-out;mock 模式轮询合成数据) ---
try:
from easy_tdx.models.enums import Market
from easy_tdx.web.quote_streamer import QuoteStreamer
from easy_tdx.web.watchlist_store import get_watchlist_store
store = get_watchlist_store()
async def _watch_symbols() -> list[tuple[Market, str]]:
# SQLite 存 "SH"/"SZ"/"BJ" 字符串,轮询器需要 Market 枚举
return [(Market[mkt], code) for mkt, code in store.symbols()]
# mock 模式不受真实限流/交易时段约束,缩短轮询间隔让 E2E 的
# SSE 断言(首帧价格渲染)秒级到达,而不是等盘外 60s 慢拍
streamer = QuoteStreamer(
client.get_security_quotes,
_watch_symbols,
trading_interval=2.0 if mock_mode else 8.0,
idle_interval=2.0 if mock_mode else 60.0,
)
streamer.start()
app.state.quote_streamer = streamer
except Exception:
logger.warning("QuoteStreamer 启动失败 — SSE 推送不可用", exc_info=True)
app.state.quote_streamer = None
# --- MAC 协议客户端 ---
mac_client: Any = None
enable_mac = getattr(app.state, "enable_mac", True)
if mock_mode:
from easy_tdx.web.e2e_mock import MockMacClient
mac_client = MockMacClient()
elif enable_mac:
try:
from easy_tdx.mac.client import AsyncMacClient
mac_client = AsyncMacClient.from_best_host()
await mac_client.connect()
logger.info("MAC client connected")
except Exception:
logger.warning("MAC client connection failed — MAC endpoints will return 503")
mac_client = None
app.state.mac_client = mac_client
# --- WebSocket 实时推送枢纽(/ws/realtime/*,按需轮询 + fan-out ---
# 与 MAC 客户端共用一条连接;无订阅时不轮询(见 realtime_hub.py)。
# mock 模式关闭交易时段过滤并支持更密轮询,供 E2E / 冒烟脚本随时可推。
app.state.realtime_hub = None
if mac_client is not None:
try:
from easy_tdx.web.realtime_hub import RealtimeStreamHub
app.state.realtime_hub = RealtimeStreamHub(
mac_client,
interval=float(os.environ.get("EASY_TDX_WS_INTERVAL", "3.0")),
sessions=() if mock_mode else None,
)
logger.info("RealtimeStreamHub 已挂载(/ws/realtime/* 就绪)")
except Exception:
logger.warning("RealtimeStreamHub 挂载失败 — WS 实时推送不可用", exc_info=True)
# --- 扩展市场客户端(可选) ---
ex_client = None
enable_ex = getattr(app.state, "enable_ex", False)
if enable_ex:
try:
from easy_tdx.ex.client import AsyncExTdxClient
ex_client = AsyncExTdxClient.from_best_host()
await ex_client.connect()
logger.info("Ex market client connected")
except Exception:
logger.warning("Ex market client connection failed — Ex endpoints will return 503")
ex_client = None
app.state.ex_client = ex_client
yield
# --- 关闭实时行情推送器 ---
streamer_svc = getattr(app.state, "quote_streamer", None)
if streamer_svc is not None:
try:
await streamer_svc.stop()
except Exception:
logger.warning("QuoteStreamer stop failed", exc_info=True)
# --- 关闭 WebSocket 实时推送枢纽(停止按需轮询任务) ---
rt_hub = getattr(app.state, "realtime_hub", None)
if rt_hub is not None:
try:
await rt_hub.shutdown()
except Exception:
logger.warning("RealtimeStreamHub shutdown failed", exc_info=True)
# --- 依次关闭 ---
for name, cli in [
("Ex market client", ex_client),
("MAC client", mac_client),
("TDX client", client),
]:
if cli is not None:
try:
await cli.close()
logger.info("%s disconnected", name)
except Exception:
pass
# --- 关闭回测任务执行器(取消 pending,等待 running ---
# shutdown 是同步阻塞调用,包在 to_thread 里避免阻塞 event loop
import asyncio
try:
from easy_tdx.web.task_runner import shutdown_runner
await asyncio.to_thread(shutdown_runner)
logger.info("Backtest task runner shutdown")
except Exception:
logger.warning("Backtest task runner shutdown failed", exc_info=True)
def _create_app(
host: str | None = None,
port: int | None = None,
timeout: float | None = None,
*,
enable_mac: bool = True,
enable_ex: bool = False,
enable_ui: bool = True,
) -> FastAPI:
"""创建并配置 FastAPI 应用实例。
Args:
enable_ui: 是否同源托管 Web UI 前端(``easy-tdx serve --no-ui``
纯 API 模式传 False:不挂载静态 dist,根路径 404,仅 /api/v1/*)。
"""
from easy_tdx.config import get_best_host, get_port, get_timeout
if host is None:
host = get_best_host()
if port is None:
port = get_port()
if timeout is None:
timeout = get_timeout()
app = FastAPI(
title="easy-tdx API",
description="通达信行情数据 REST + WebSocket API",
version="1.0.0",
lifespan=lifespan,
redoc_url=None, # 手动注册 redoc 端点以控制 JS CDN URL
)
# 手动注册 ReDoc 端点,使用固定版本的 JS(默认 redoc@next 已 404
from fastapi.openapi.docs import get_redoc_html
@app.get("/redoc", include_in_schema=False)
async def redoc_html() -> Any:
return get_redoc_html(
openapi_url=app.openapi_url or "/openapi.json",
title=app.title + " - ReDoc",
redoc_js_url="https://cdn.jsdelivr.net/npm/redoc@2.2.0/bundles/redoc.standalone.js",
)
# Store connection config in app.state for lifespan to use
app.state.tdx_host = host
app.state.tdx_port = port
app.state.tdx_timeout = timeout
app.state.tdx_client = None # will be set in lifespan
app.state.mac_client = None
app.state.ex_client = None
app.state.enable_mac = enable_mac
app.state.enable_ex = enable_ex
# CORS middleware (permissive for development)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Register exception handlers
register_exception_handlers(app)
# Mount routers
from easy_tdx.web.routers.announcement import router as announcement_router
from easy_tdx.web.routers.backtest import router as backtest_router
from easy_tdx.web.routers.bars import router as bars_router
from easy_tdx.web.routers.block import router as block_router
from easy_tdx.web.routers.board_mac import router as board_mac_router
from easy_tdx.web.routers.chanlun import router as chanlun_router
from easy_tdx.web.routers.ex_market import router as ex_market_router
from easy_tdx.web.routers.finance import router as finance_router
from easy_tdx.web.routers.formula import router as formula_router
from easy_tdx.web.routers.indicator import router as indicator_router
from easy_tdx.web.routers.mac_data import router as mac_data_router
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
from easy_tdx.web.routers.stream import router as stream_router
from easy_tdx.web.routers.watchlist import router as watchlist_router
app.include_router(market_router, prefix="/api/v1")
app.include_router(bars_router, prefix="/api/v1")
app.include_router(finance_router, prefix="/api/v1")
app.include_router(formula_router, prefix="/api/v1")
app.include_router(block_router, prefix="/api/v1")
app.include_router(chanlun_router, prefix="/api/v1")
app.include_router(realtime_router, prefix="/api/v1")
# MAC 协议路由
app.include_router(board_mac_router, prefix="/api/v1")
app.include_router(mac_data_router, prefix="/api/v1")
app.include_router(mac_quotes_router, prefix="/api/v1")
# 扩展市场路由
app.include_router(ex_market_router, prefix="/api/v1")
# 技术指标路由
app.include_router(indicator_router, prefix="/api/v1")
# 公告检索路由(巨潮资讯网,独立数据源)
app.include_router(announcement_router, prefix="/api/v1")
# 新浪财报三表路由(独立数据源)
app.include_router(sina_router, prefix="/api/v1")
# 回测路由(纯计算,不依赖行情连接 lifespan)
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")
# 自选股路由(SQLite 持久化,纯 CRUD,不依赖行情连接)
app.include_router(watchlist_router, prefix="/api/v1")
# 实时行情 SSE 路由(依赖 lifespan 里的 QuoteStreamer
app.include_router(stream_router, prefix="/api/v1")
# --- 前端 dist 托管(生产/打包态同源服务,开发态可缺省) ---
# 必须在所有 API 路由注册之后:StaticFiles(html=True) 挂在 "/" 会吞掉
# 未匹配路径,放最后保证 /api/v1/* 优先命中路由表。
# 强制注册 MIME 类型:干净 Windows(无开发环境)的注册表里可能没有
# .js/.mjs/.css 的 Content Type,导致 mimetypes.guess_type 返回 None
# StaticFiles 回退到 text/plain。浏览器的 <script type="module"> 启用
# 严格 MIME 检查,拒绝执行 text/plain 的 JS → 页面纯黑(v1.19.1 bug)。
# add_type 会覆盖/补充注册表缺失的映射,无论机器装没装开发工具都生效。
import mimetypes
mimetypes.add_type("application/javascript", ".js")
mimetypes.add_type("application/javascript", ".mjs")
mimetypes.add_type("text/css", ".css")
mimetypes.add_type("image/svg+xml", ".svg")
from fastapi.staticfiles import StaticFiles
dist_dir = _resolve_web_dist_dir() if enable_ui else None
if dist_dir is not None:
# SPA fallback:前端用 createWebHistoryHTML5 history 模式),
# 用户直接访问 /optimize、/portfolio 等前端路由或刷新时,后端必须
# 返回 index.html 让 Vue Router 接管,而不是 404。
# 实现方式:用 StaticFiles 挂在 "/static" 提供真实文件(JS/CSS/图标),
# 再加一个 catch-all 路由把所有非 /api、非 /static 的 GET 请求导向
# index.html。但这样会改变 JS/CSS 的 URL 前缀(/assets → /static/assets),
# 需要改 vite base 配置,代价大。
# 更简单的方式:先尝试 StaticFiles 服务真实文件,找不到时 fallback。
# Starlette 的 StaticFiles(html=True) 不做 SPA fallback,故子类化它。
from pathlib import Path as _Path
from starlette.responses import FileResponse
class SPAStaticFiles(StaticFiles):
"""StaticFiles + SPA fallback404 时返回 index.html。"""
async def get_response(self, path: str, scope): # type: ignore[no-untyped-def]
try:
return await super().get_response(path, scope)
except Exception:
# 任何 404(路径非文件)都返回 index.html,让前端路由处理。
# 仅对 GET 请求生效;API 路径 (/api/v1/*) 已在前面注册,
# 不会走到这里。
index = _Path(str(self.directory)) / "index.html"
if index.is_file():
return FileResponse(str(index))
raise
app.mount("/", SPAStaticFiles(directory=str(dist_dir), html=True), name="web-ui")
logger.info("Web UI mounted from %s (SPA fallback enabled)", dist_dir)
else:
logger.info("Web UI dist not found — serving API only")
return app