feat(web): add FastAPI app factory, all routers, CLI serve command, and tests

- App factory with lifespan management and CORS middleware
- Market router: security list, quotes, market stat, fund-flow
- Bars router: kline, index kline, minute, transaction
- Finance router: xdxr, finance, company info, financial records
- Block router: block file parsing
- Chanlun router: POST /chanlun/analyze
- Realtime router: WebSocket /ws/realtime/{symbol}
- CLI: easy-tdx serve command
- 16 unit tests, all passing offline (no network)
This commit is contained in:
Justin Gu
2026-06-12 03:08:12 +08:00
parent 9dc70566a5
commit eb8a7a5675
11 changed files with 852 additions and 1 deletions
+2
View File
@@ -29,6 +29,7 @@ from .cmd_quote import quote, quote_list
from .cmd_run_all import run_all
from .cmd_tick import tick
from .cmd_transaction import transaction
from .cmd_web import serve
@click.group()
@@ -84,3 +85,4 @@ cli.add_command(backtest)
cli.add_command(portfolio)
cli.add_command(run_all)
cli.add_command(screen)
cli.add_command(serve)
+37
View File
@@ -0,0 +1,37 @@
"""easy-tdx serve — 启动 Web API 服务器。"""
from __future__ import annotations
import click
@click.command("serve")
@click.option("--host", default="0.0.0.0", help="监听地址")
@click.option("--port", default=8000, type=int, help="监听端口")
@click.option("--tdx-host", default=None, help="TDX 服务器地址(默认自动选择最优)")
@click.option("--tdx-port", default=None, type=int, help="TDX 服务器端口")
@click.option("--reload", is_flag=True, help="开发模式(自动重载)")
def serve(host: str, port: int, tdx_host: str | None, tdx_port: int | None, reload: bool) -> None:
"""启动 Web API 服务器(需要安装 easy-tdx[web])。"""
try:
import uvicorn
except ImportError:
click.echo(
"错误:缺少 web 依赖。请运行: pip install easy-tdx[web]",
err=True,
)
raise SystemExit(1) from None
if reload:
uvicorn.run(
"easy_tdx.web:app_factory",
host=host,
port=port,
reload=True,
factory=True,
)
else:
from easy_tdx.web import create_app
app = create_app(host=tdx_host, port=tdx_port)
uvicorn.run(app, host=host, port=port)
+98
View File
@@ -0,0 +1,98 @@
"""FastAPI application factory and lifespan management."""
from __future__ import annotations
import logging
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from easy_tdx.web.errors import register_exception_handlers
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""管理 TDX 连接生命周期:启动时连接,关闭时断开。"""
from easy_tdx.client import AsyncTdxClient
host = app.state.tdx_host
port = app.state.tdx_port
timeout = app.state.tdx_timeout
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
yield
try:
await client.close()
logger.info("TDX client disconnected")
except Exception:
pass
def _create_app(
host: str | None = None,
port: int | None = None,
timeout: float | None = None,
) -> FastAPI:
"""创建并配置 FastAPI 应用实例。"""
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,
)
# 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
# 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.bars import router as bars_router
from easy_tdx.web.routers.block import router as block_router
from easy_tdx.web.routers.chanlun import router as chanlun_router
from easy_tdx.web.routers.finance import router as finance_router
from easy_tdx.web.routers.market import router as market_router
from easy_tdx.web.routers.realtime import router as realtime_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(block_router, prefix="/api/v1")
app.include_router(chanlun_router, prefix="/api/v1")
app.include_router(realtime_router, prefix="/api/v1")
return app
+16
View File
@@ -0,0 +1,16 @@
"""Dependency injection for Web API routers."""
from __future__ import annotations
import typing
from typing import TYPE_CHECKING
from fastapi import Request
if TYPE_CHECKING:
from easy_tdx.client import AsyncTdxClient
def get_client(request: Request) -> AsyncTdxClient:
"""从 app.state 获取共享的 AsyncTdxClient 实例。"""
return typing.cast(AsyncTdxClient, request.app.state.tdx_client)
+113
View File
@@ -0,0 +1,113 @@
"""K线 / 分时 / 逐笔成交路由。"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Query
from easy_tdx.web.deps import get_client
from easy_tdx.web.schemas import DataFrameResponse, KlineCategoryEnum
router = APIRouter(tags=["bars"])
def _market(market: str) -> Any:
from easy_tdx.models.enums import Market
return Market[market.upper()]
def _category(category: str) -> Any:
from easy_tdx.models.enums import KlineCategory
# Support both int and string name
try:
return KlineCategory(int(category))
except (ValueError, TypeError):
return KlineCategory[KlineCategoryEnum[category.upper()].name]
def _df_resp(df: Any) -> DataFrameResponse:
return DataFrameResponse.from_dataframe(df)
@router.get("/bars", response_model=DataFrameResponse)
async def security_bars(
market: str = Query(..., description="市场: SZ, SH, BJ"),
code: str = Query(..., min_length=6, max_length=6),
category: str = Query(
"DAY",
description="K线周期: MIN_1, MIN_5, MIN_15, MIN_30, MIN_60, DAY, WEEK, MONTH, YEAR",
),
start: int = Query(0, ge=0),
count: int = Query(800, ge=1, le=800),
client: Any = Depends(get_client),
) -> DataFrameResponse:
"""获取股票K线数据。"""
df = await client.get_security_bars(_market(market), code, _category(category), start, count)
return _df_resp(df)
@router.get("/bars/index", response_model=DataFrameResponse)
async def index_bars(
market: str = Query(..., description="市场: SZ, SH"),
code: str = Query(..., min_length=6, max_length=6),
category: str = Query("DAY", description="K线周期"),
start: int = Query(0, ge=0),
count: int = Query(800, ge=1, le=800),
client: Any = Depends(get_client),
) -> DataFrameResponse:
"""获取指数K线数据。"""
df = await client.get_index_bars(_market(market), code, _category(category), start, count)
return _df_resp(df)
@router.get("/minute", response_model=DataFrameResponse)
async def minute_time(
market: str = Query(..., description="市场: SZ, SH"),
code: str = Query(..., min_length=6, max_length=6),
client: Any = Depends(get_client),
) -> DataFrameResponse:
"""获取今日分时数据。"""
df = await client.get_minute_time_data(_market(market), code)
return _df_resp(df)
@router.get("/minute/history", response_model=DataFrameResponse)
async def history_minute_time(
market: str = Query(..., description="市场: SZ, SH"),
code: str = Query(..., min_length=6, max_length=6),
date: int = Query(..., description="日期 YYYYMMDD"),
client: Any = Depends(get_client),
) -> DataFrameResponse:
"""获取历史某日分时数据。"""
df = await client.get_history_minute_time_data(_market(market), code, date)
return _df_resp(df)
@router.get("/transaction", response_model=DataFrameResponse)
async def transaction_data(
market: str = Query(..., description="市场: SZ, SH"),
code: str = Query(..., min_length=6, max_length=6),
start: int = Query(0, ge=0),
count: int = Query(800, ge=1, le=800),
client: Any = Depends(get_client),
) -> DataFrameResponse:
"""获取当日逐笔成交。"""
df = await client.get_transaction_data(_market(market), code, start, count)
return _df_resp(df)
@router.get("/transaction/history", response_model=DataFrameResponse)
async def history_transaction_data(
market: str = Query(..., description="市场: SZ, SH"),
code: str = Query(..., min_length=6, max_length=6),
date: int = Query(..., description="日期 YYYYMMDD"),
start: int = Query(0, ge=0),
count: int = Query(800, ge=1, le=800),
client: Any = Depends(get_client),
) -> DataFrameResponse:
"""获取历史逐笔成交。"""
df = await client.get_history_transaction_data(_market(market), code, date, start, count)
return _df_resp(df)
+29
View File
@@ -0,0 +1,29 @@
"""板块信息路由。"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Query
from easy_tdx.web.deps import get_client
from easy_tdx.web.schemas import DataFrameResponse
router = APIRouter(tags=["block"])
def _df_resp(df: Any) -> DataFrameResponse:
return DataFrameResponse.from_dataframe(df)
@router.get("/block", response_model=DataFrameResponse)
async def block_info(
filename: str = Query(
...,
description=("板块文件名: block_zs.dat(行业指数), block_gn.dat(概念), block_fg.dat(风格)"),
),
client: Any = Depends(get_client),
) -> DataFrameResponse:
"""获取并解析板块文件。"""
df = await client.get_block_info(filename)
return _df_resp(df)
+64
View File
@@ -0,0 +1,64 @@
"""缠论分析路由。"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends
from easy_tdx.web.deps import get_client
from easy_tdx.web.schemas import ChanlunRequest
router = APIRouter(tags=["chanlun"])
def _market(market: str) -> Any:
from easy_tdx.models.enums import Market
return Market[market.upper()]
def _category(category: str) -> Any:
from easy_tdx.models.enums import KlineCategory
try:
return KlineCategory(int(category))
except (ValueError, TypeError):
return KlineCategory[category.upper()]
@router.post("/chanlun/analyze")
async def chanlun_analyze(
req: ChanlunRequest,
client: Any = Depends(get_client),
) -> dict[str, Any]:
"""执行缠论分析。
自动从 TDX 服务器获取 K 线数据,运行完整缠论计算管道,
返回笔、中枢、线段、买卖点、背驰等分析结果。
"""
from easy_tdx.chanlun import ChanlunAnalyser
# 1. Fetch kline data
df = await client.get_security_bars(
_market(req.market), req.code, _category(req.category), req.start, req.count
)
# 2. Run chanlun analysis
symbol = f"{req.market}{req.code}"
frequency_map: dict[str, str] = {
"MIN_1": "1min",
"MIN_5": "5min",
"MIN_15": "15min",
"MIN_30": "30min",
"MIN_60": "60min",
"DAY": "daily",
"WEEK": "weekly",
"MONTH": "monthly",
"YEAR": "yearly",
}
freq = frequency_map.get(req.category.upper(), req.category)
analyser = ChanlunAnalyser(code=symbol, frequency=freq)
result = analyser.process_klines(df)
return result.to_dict()
+88
View File
@@ -0,0 +1,88 @@
"""财务 / 公司信息路由。"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Query
from easy_tdx.web.deps import get_client
from easy_tdx.web.schemas import DataFrameResponse
router = APIRouter(tags=["finance"])
def _market(market: str) -> Any:
from easy_tdx.models.enums import Market
return Market[market.upper()]
def _df_resp(df: Any) -> DataFrameResponse:
return DataFrameResponse.from_dataframe(df)
@router.get("/xdxr", response_model=DataFrameResponse)
async def xdxr_info(
market: str = Query(..., description="市场: SZ, SH"),
code: str = Query(..., min_length=6, max_length=6),
client: Any = Depends(get_client),
) -> DataFrameResponse:
"""获取除权除息历史记录。"""
df = await client.get_xdxr_info(_market(market), code)
return _df_resp(df)
@router.get("/finance", response_model=DataFrameResponse)
async def finance_info(
market: str = Query(..., description="市场: SZ, SH"),
code: str = Query(..., min_length=6, max_length=6),
client: Any = Depends(get_client),
) -> DataFrameResponse:
"""获取最新财务数据。"""
df = await client.get_finance_info(_market(market), code)
return _df_resp(df)
@router.get("/company/category", response_model=DataFrameResponse)
async def company_info_category(
market: str = Query(..., description="市场: SZ, SH"),
code: str = Query(..., min_length=6, max_length=6),
client: Any = Depends(get_client),
) -> DataFrameResponse:
"""获取公司信息文件目录。"""
df = await client.get_company_info_category(_market(market), code)
return _df_resp(df)
@router.get("/company/content")
async def company_info_content(
market: str = Query(..., description="市场: SZ, SH"),
code: str = Query(..., min_length=6, max_length=6),
filename: str = Query(..., description="文件名"),
offset: int = Query(0, ge=0),
length: int = Query(1024, ge=1),
client: Any = Depends(get_client),
) -> dict[str, str]:
"""读取公司信息文本。"""
content = await client.get_company_info_content(_market(market), code, filename, offset, length)
return {"content": content}
@router.get("/financial/file-list", response_model=DataFrameResponse)
async def financial_file_list(
client: Any = Depends(get_client),
) -> DataFrameResponse:
"""获取可用的历史专业财报文件列表。"""
df = await client.get_financial_file_list()
return _df_resp(df)
@router.get("/financial/records", response_model=DataFrameResponse)
async def financial_records(
filename: str = Query(..., description="财报文件名,如 tdxfin/gpcw20260331.zip"),
client: Any = Depends(get_client),
) -> DataFrameResponse:
"""下载财报 zip 并解析为记录列表。"""
df = await client.get_financial_records(filename)
return _df_resp(df)
+109
View File
@@ -0,0 +1,109 @@
"""市场信息路由:证券列表、实时行情、市场统计、资金流向。"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Query
from easy_tdx.web.deps import get_client
from easy_tdx.web.schemas import (
CountResponse,
DataFrameResponse,
MarketEnum,
QuoteRequest,
)
router = APIRouter(tags=["market"])
def _market_from_str(s: str) -> Any:
"""将字符串转为 Market 枚举。"""
from easy_tdx.models.enums import Market
return Market[MarketEnum[s].name]
def _df_response(df: Any) -> DataFrameResponse:
"""将 DataFrame 转为 API 响应。"""
return DataFrameResponse.from_dataframe(df)
@router.get("/security/count", response_model=CountResponse)
async def security_count(
market: str = Query(..., description="市场: SZ, SH, BJ"),
client: Any = Depends(get_client),
) -> CountResponse:
"""获取市场证券总数。"""
count = await client.get_security_count(_market_from_str(market))
return CountResponse(count=count)
@router.get("/security/list", response_model=DataFrameResponse)
async def security_list(
market: str = Query(..., description="市场: SZ, SH, BJ"),
start: int = Query(0, ge=0, description="分页起始位置"),
client: Any = Depends(get_client),
) -> DataFrameResponse:
"""获取证券列表(每页约1000条)。"""
df = await client.get_security_list(_market_from_str(market), start)
return _df_response(df)
@router.get("/security/list-all", response_model=DataFrameResponse)
async def security_list_all(
pages: int = Query(1, ge=1, description="拉取页数(每个市场每页1000条)"),
client: Any = Depends(get_client),
) -> DataFrameResponse:
"""获取沪深 A 股完整列表。"""
df = await client.get_security_list_all(pages=pages)
return _df_response(df)
@router.post("/quotes", response_model=DataFrameResponse)
async def security_quotes(
req: QuoteRequest,
client: Any = Depends(get_client),
) -> DataFrameResponse:
"""批量获取实时五档行情(最多80只/次)。"""
from easy_tdx.models.enums import Market
stocks_parsed: list[tuple[Any, str]] = []
for s in req.stocks:
m = Market[MarketEnum[s.market].name]
stocks_parsed.append((m, s.code))
df = await client.get_security_quotes(stocks_parsed)
return _df_response(df)
@router.get("/market/stat", response_model=DataFrameResponse)
async def market_stat(
client: Any = Depends(get_client),
) -> DataFrameResponse:
"""获取 A 股全市场涨跌统计。"""
df = await client.get_market_stat()
return _df_response(df)
@router.get("/fund-flow", response_model=DataFrameResponse)
async def fund_flow(
market: str = Query(..., description="市场: SZ, SH"),
code: str = Query(..., min_length=6, max_length=6, description="6位股票代码"),
client: Any = Depends(get_client),
) -> DataFrameResponse:
"""获取个股当日资金流向。"""
df = await client.get_fund_flow(_market_from_str(market), code)
return _df_response(df)
@router.get("/fund-flow/history", response_model=DataFrameResponse)
async def history_fund_flow(
market: str = Query(..., description="市场: SZ, SH"),
code: str = Query(..., min_length=6, max_length=6, description="6位股票代码"),
start: int = Query(0, ge=0),
count: int = Query(100, ge=1, le=800),
client: Any = Depends(get_client),
) -> DataFrameResponse:
"""获取个股历史日线资金流向。"""
df = await client.get_history_fund_flow(_market_from_str(market), code, start, count)
return _df_response(df)
+103
View File
@@ -0,0 +1,103 @@
"""实时数据 WebSocket 路由。"""
from __future__ import annotations
import asyncio
import json
import logging
from typing import Any
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
logger = logging.getLogger(__name__)
router = APIRouter(tags=["realtime"])
@router.websocket("/ws/realtime/{symbol}")
async def realtime_websocket(websocket: WebSocket, symbol: str) -> None:
"""WebSocket 实时行情订阅。
连接后自动订阅指定标的的实时事件。
symbol 格式: SZ000001, SH600000 等。
客户端可发送 JSON 消息来控制订阅:
- {"action": "subscribe", "symbol": "SZ000001"}
- {"action": "unsubscribe", "symbol": "SZ000001"}
服务端推送消息格式:
- {"type": "tick", "market": "SZ", "code": "000001", "price": 10.5, ...}
- {"type": "signal", "direction": "BUY", ...}
"""
await websocket.accept()
logger.info("WebSocket client connected for symbol: %s", symbol)
# Try to get EventBus from app state
event_bus = getattr(websocket.app.state, "event_bus", None)
subscribed_symbols: set[str] = {symbol.upper()}
async def _on_event(event: Any) -> None:
"""EventBus 回调 → 推送 WebSocket 消息。"""
event_symbol = f"{event.market}{event.code}"
if event_symbol in subscribed_symbols:
try:
msg = {
"type": event.event_type.value,
"market": event.market,
"code": event.code,
"price": event.price,
"volume": event.volume,
"timestamp": event.timestamp,
"data": event.data,
}
await websocket.send_json(msg)
except Exception:
logger.warning("Failed to send WebSocket message")
# Subscribe to event bus if available
if event_bus is not None:
event_bus.subscribe_all(_on_event)
try:
while True:
# Receive client messages (subscribe/unsubscribe control)
try:
raw = await asyncio.wait_for(websocket.receive_text(), timeout=30.0)
data = json.loads(raw)
action = data.get("action", "")
if action == "subscribe":
new_symbol = data.get("symbol", "").upper()
if new_symbol:
subscribed_symbols.add(new_symbol)
await websocket.send_json(
{"type": "status", "msg": f"subscribed {new_symbol}"}
)
elif action == "unsubscribe":
old_symbol = data.get("symbol", "").upper()
subscribed_symbols.discard(old_symbol)
await websocket.send_json(
{"type": "status", "msg": f"unsubscribed {old_symbol}"}
)
except asyncio.TimeoutError:
# Send heartbeat ping
try:
await websocket.send_json({"type": "ping"})
except Exception:
break
except WebSocketDisconnect:
break
except json.JSONDecodeError:
await websocket.send_json({"type": "error", "msg": "invalid JSON"})
except WebSocketDisconnect:
logger.info("WebSocket client disconnected: %s", symbol)
except Exception:
logger.exception("WebSocket error for %s", symbol)
finally:
if event_bus is not None:
event_bus.unsubscribe(symbol.upper(), _on_event)
logger.info("WebSocket connection closed: %s", symbol)