From d17895e92bfccfa9417c7e9295bd6d398ef4ef15 Mon Sep 17 00:00:00 2001 From: GitHub Date: Fri, 12 Jun 2026 16:46:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20full=20Web=20API=20parity=20with?= =?UTF-8?q?=20CLI=20=E2=80=94=2018=20new=20endpoints=20(v1.10.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Board analysis: list/members/belong/summary/ranking/change-ranking (6) - Capital flow, symbol info, server info (3) - Quote list, auction, unusual (3) - Extended market: bars/quote/minute/transaction (4) - Technical indicators: list + compute (2) - Multi-client DI: AsyncMacClient + AsyncExTdxClient lifecycle - 6 MAC enum converters, DictResponse, ComputeIndicatorsRequest schemas - Web API endpoints: 22 → 40 Co-Authored-By: Claude --- README.md | 83 ++++++++++++++++-- pyproject.toml | 2 +- src/easy_tdx/web/app.py | 69 +++++++++++++-- src/easy_tdx/web/convert.py | 116 ++++++++++++++++++++++++- src/easy_tdx/web/deps.py | 18 ++++ src/easy_tdx/web/routers/board_mac.py | 116 +++++++++++++++++++++++++ src/easy_tdx/web/routers/ex_market.py | 83 ++++++++++++++++++ src/easy_tdx/web/routers/indicator.py | 45 ++++++++++ src/easy_tdx/web/routers/mac_data.py | 48 ++++++++++ src/easy_tdx/web/routers/mac_quotes.py | 69 +++++++++++++++ src/easy_tdx/web/schemas.py | 36 ++++++++ 11 files changed, 672 insertions(+), 13 deletions(-) create mode 100644 src/easy_tdx/web/routers/board_mac.py create mode 100644 src/easy_tdx/web/routers/ex_market.py create mode 100644 src/easy_tdx/web/routers/indicator.py create mode 100644 src/easy_tdx/web/routers/mac_data.py create mode 100644 src/easy_tdx/web/routers/mac_quotes.py diff --git a/README.md b/README.md index cfe9e52..4030830 100644 --- a/README.md +++ b/README.md @@ -773,6 +773,7 @@ easy-tdx serve --reload ### REST API 示例 ```bash +# ── 基础行情 ── # 获取深圳市场证券数量 curl "http://localhost:8000/api/v1/security/count?market=SZ" @@ -784,16 +785,71 @@ curl -X POST "http://localhost:8000/api/v1/quotes" \ -H "Content-Type: application/json" \ -d '{"stocks": [{"market": "SZ", "code": "000001"}, {"market": "SH", "code": "600000"}]}' -# 缠论分析 -curl -X POST "http://localhost:8000/api/v1/chanlun/analyze" \ - -H "Content-Type: application/json" \ - -d '{"market": "SZ", "code": "000001", "category": "DAY", "count": 200}' - # 市场统计 curl "http://localhost:8000/api/v1/market/stat" -# 板块信息 +# 板块信息(标准协议) curl "http://localhost:8000/api/v1/block?filename=block_gn.dat" + +# ── 板块分析(MAC 协议)── +# 行业板块列表 +curl "http://localhost:8000/api/v1/board-mac/list?board_type=HY&count=50" + +# 板块成分股(按涨幅排序) +curl "http://localhost:8000/api/v1/board-mac/members?board_symbol=881001&count=20" + +# 个股所属板块 +curl "http://localhost:8000/api/v1/board-mac/belong?market=SZ&code=000001" + +# 板块摘要(含主力净流入、涨跌家数) +curl "http://localhost:8000/api/v1/board-mac/summary?board_symbol=881001" + +# 行业板块涨幅排名 Top 10 +curl "http://localhost:8000/api/v1/board-mac/ranking?board_type=HY&top_n=10" + +# 板块 20 日涨幅排行 +curl "http://localhost:8000/api/v1/board-mac/change-ranking?board_type=HY&days=20&top_n=10" + +# ── 资金 / 信息 ── +# 个股资金流向(主力/散户净流入) +curl "http://localhost:8000/api/v1/mac/capital-flow?market=SH&code=600519" + +# 个股基本信息快照 +curl "http://localhost:8000/api/v1/mac/symbol-info?market=SZ&code=000001" + +# 服务器交易时段信息 +curl "http://localhost:8000/api/v1/mac/server-info" + +# ── 排行 / 竞价 / 异动 ── +# 全 A 涨幅排行前 20 +curl "http://localhost:8000/api/v1/mac/quote-list?category=A&count=20&sort_type=CHANGE_PCT" + +# 集合竞价数据 +curl "http://localhost:8000/api/v1/mac/auction?market=SZ&code=000001" + +# 市场异动行情 +curl "http://localhost:8000/api/v1/mac/unusual?market=SH&count=50" + +# ── 扩展市场(期货/港股/美股)── +# 港股 K 线 +curl "http://localhost:8000/api/v1/ex/bars?market=HK_MAIN_BOARD&code=00700&category=DAY&count=30" + +# 美股实时报价 +curl "http://localhost:8000/api/v1/ex/quote?market=US_STOCK&code=AAPL" + +# ── 技术指标 ── +# 列出所有可用指标 +curl "http://localhost:8000/api/v1/indicator/list" + +# 计算 MACD + KDJ 指标 +curl -X POST "http://localhost:8000/api/v1/indicator/compute" \ + -H "Content-Type: application/json" \ + -d '{"data": [{"open":10,"close":10.5,"high":11,"low":9.5,"vol":1000}], "indicators": ["MACD", "KDJ"]}' + +# ── 缠论分析 ── +curl -X POST "http://localhost:8000/api/v1/chanlun/analyze" \ + -H "Content-Type: application/json" \ + -d '{"market": "SZ", "code": "000001", "category": "DAY", "count": 200}' ``` ### WebSocket 实时行情 @@ -1415,6 +1471,21 @@ ruff format --check src/ tests/ # format check ## Changelog +### 1.10.2 (2026-06-12) + +**Web API 全面补齐** — 新增 18 个 REST 端点,Web API 与 CLI 接口覆盖对齐。 + +- **板块分析(6 端点)**:板块列表、成分股、所属板块、板块摘要、涨幅排名、N日涨幅排行 +- **资金/信息(3 端点)**:个股资金流向、个股信息快照、服务器交易时段 +- **排行/竞价/异动(3 端点)**:分类排序行情列表、集合竞价、市场异动 +- **扩展市场(4 端点)**:港股/美股/期货的 K 线、报价、分时、逐笔成交 +- **技术指标(2 端点)**:指标列表、指标计算(POST) +- 新增 `AsyncMacClient` 依赖注入(`get_mac_client`),Web 层同时管理 TDX + MAC 双客户端连接 +- 新增 `AsyncExTdxClient` 依赖注入(`get_ex_client`),可选启用扩展市场端点 +- 新增 6 个 MAC 枚举转换器(BoardType/SortType/SortOrder/Category/ExMarket/FilterType) +- 新增 `DictResponse` 和 `ComputeIndicatorsRequest` schemas +- Web API 端点总数从 22 增至 40 + ### 1.10.0 (2026-06-12) **Web API 层** — 新增 FastAPI REST + WebSocket 服务,一键将 easy-tdx 暴露为 HTTP API。 diff --git a/pyproject.toml b/pyproject.toml index f9dffce..cd5d0d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "easy-tdx" -version = "1.10.0" +version = "1.10.2" description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步" readme = "README.md" requires-python = ">=3.10" diff --git a/src/easy_tdx/web/app.py b/src/easy_tdx/web/app.py index f3bec70..191d381 100644 --- a/src/easy_tdx/web/app.py +++ b/src/easy_tdx/web/app.py @@ -19,6 +19,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: """管理 TDX 连接生命周期:启动时连接,关闭时断开。""" from easy_tdx.client import AsyncTdxClient + # --- 标准 TDX 客户端 --- host = app.state.tdx_host port = app.state.tdx_port timeout = app.state.tdx_timeout @@ -31,19 +32,60 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: logger.warning("TDX client connection failed — endpoints will return 503") app.state.tdx_client = client + + # --- MAC 协议客户端 --- + mac_client = None + enable_mac = getattr(app.state, "enable_mac", True) + if 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 + + # --- 扩展市场客户端(可选) --- + 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 - try: - await client.close() - logger.info("TDX client disconnected") - except Exception: - pass + # --- 依次关闭 --- + 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 def _create_app( host: str | None = None, port: int | None = None, timeout: float | None = None, + *, + enable_mac: bool = True, + enable_ex: bool = False, ) -> FastAPI: """创建并配置 FastAPI 应用实例。""" from easy_tdx.config import get_best_host, get_port, get_timeout @@ -67,6 +109,10 @@ def _create_app( 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( @@ -83,8 +129,13 @@ def _create_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.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.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 @@ -94,5 +145,13 @@ def _create_app( 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") return app diff --git a/src/easy_tdx/web/convert.py b/src/easy_tdx/web/convert.py index da9e4de..dac8046 100644 --- a/src/easy_tdx/web/convert.py +++ b/src/easy_tdx/web/convert.py @@ -1,4 +1,4 @@ -"""共享参数转换工具(market/category 字符串 → 枚举)。""" +"""共享参数转换工具(字符串 → 枚举)。""" from __future__ import annotations @@ -24,6 +24,11 @@ def market_from_str(s: str) -> Any: raise ValueError(f"无效市场代码 '{s}',可选值: {valid}") from None +def market_value_from_str(s: str) -> int: + """将市场字符串转为 int 值(MAC 客户端使用 int 而非枚举)。""" + return int(market_from_str(s).value) + + def category_from_str(s: str) -> Any: """将字符串转为 KlineCategory 枚举,支持大小写和数字字符串。""" from easy_tdx.models.enums import KlineCategory @@ -39,3 +44,112 @@ def category_from_str(s: str) -> Any: except KeyError: valid = ", ".join(c.name for c in KlineCategoryEnum) raise ValueError(f"无效K线周期 '{s}',可选值: {valid}") from None + + +# --------------------------------------------------------------------------- +# MAC 枚举转换器 +# --------------------------------------------------------------------------- + + +def board_type_from_str(s: str) -> Any: + """将字符串转为 BoardType 枚举(ALL/HY/HY2/GN/FG/DQ/...)。""" + from easy_tdx.mac.enums import BoardType + + key = s.upper() + try: + return BoardType[key] + except KeyError: + pass + try: + return BoardType(int(key)) + except (ValueError, TypeError): + pass + valid = ", ".join(m.name for m in BoardType) + raise ValueError(f"无效板块类型 '{s}',可选值: {valid}") from None + + +def sort_type_from_str(s: str) -> Any: + """将字符串转为 SortType 枚举(CHANGE_PCT/VOLUME/... 或 hex 数字)。""" + from easy_tdx.mac.enums import SortType + + key = s.upper() + try: + return SortType[key] + except KeyError: + pass + try: + return SortType(int(key, 0)) # 支持 hex 如 "0x0E" + except (ValueError, TypeError): + pass + valid = ", ".join(m.name for m in SortType) + raise ValueError(f"无效排序字段 '{s}',可选值: {valid}") from None + + +def sort_order_from_str(s: str) -> Any: + """将字符串转为 SortOrder 枚举(ASC/DESC)。""" + from easy_tdx.mac.enums import SortOrder + + key = s.upper() + try: + return SortOrder[key] + except KeyError: + pass + try: + return SortOrder(int(key)) + except (ValueError, TypeError): + pass + valid = ", ".join(m.name for m in SortOrder) + raise ValueError(f"无效排序方向 '{s}',可选值: {valid}") from None + + +def category_mac_from_str(s: str) -> Any: + """将字符串转为 MAC Category 枚举(A/SH/SZ/KCB/BJ/CYB/...)。""" + from easy_tdx.mac.enums import Category + + key = s.upper() + try: + return Category[key] + except KeyError: + pass + try: + return Category(int(key)) + except (ValueError, TypeError): + pass + valid = ", ".join(m.name for m in Category if m < 10000) + raise ValueError(f"无效市场分类 '{s}',可选值: {valid}") from None + + +def ex_market_from_str(s: str) -> int: + """将字符串转为 ExMarket 整数值(HK_MAIN_BOARD/COMEX_FUTURES/... 或数字)。""" + from easy_tdx.mac.enums import ExMarket + + try: + return int(ExMarket[s.upper()]) + except KeyError: + pass + try: + return int(s) + except (ValueError, TypeError): + pass + valid = ", ".join(m.name for m in ExMarket) + raise ValueError(f"无效扩展市场代码 '{s}',可选值: {valid}") from None + + +def filter_types_from_str(s: str) -> list[Any]: + """将逗号分隔字符串转为 FilterType 列表(ST,KC,BJ,...)。""" + from easy_tdx.mac.enums import FilterType + + if not s: + return [] + result: list[Any] = [] + for part in s.split(","): + key = part.strip().upper() + try: + result.append(FilterType[key]) + except KeyError: + try: + result.append(FilterType(int(key))) + except (ValueError, TypeError): + valid = ", ".join(m.name for m in FilterType) + raise ValueError(f"无效过滤标志 '{part}',可选值: {valid}") from None + return result diff --git a/src/easy_tdx/web/deps.py b/src/easy_tdx/web/deps.py index f5c0881..a1b8386 100644 --- a/src/easy_tdx/web/deps.py +++ b/src/easy_tdx/web/deps.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Any + from fastapi import Request from easy_tdx.client import AsyncTdxClient @@ -11,3 +13,19 @@ def get_client(request: Request) -> AsyncTdxClient: """从 app.state 获取共享的 AsyncTdxClient 实例。""" client: AsyncTdxClient = request.app.state.tdx_client return client + + +def get_mac_client(request: Request) -> Any: + """从 app.state 获取共享的 AsyncMacClient 实例。""" + client: Any = request.app.state.mac_client + return client + + +def get_ex_client(request: Request) -> Any: + """从 app.state 获取共享的 AsyncExTdxClient 实例(可选)。""" + client: Any | None = request.app.state.ex_client + if client is None: + from easy_tdx.exceptions import TdxConnectionError + + raise TdxConnectionError("扩展市场客户端未启用") + return client diff --git a/src/easy_tdx/web/routers/board_mac.py b/src/easy_tdx/web/routers/board_mac.py new file mode 100644 index 0000000..0af822d --- /dev/null +++ b/src/easy_tdx/web/routers/board_mac.py @@ -0,0 +1,116 @@ +"""板块分析路由:板块列表、成分、归属、摘要、涨幅排名、N日涨幅。""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, Query + +from easy_tdx.web.convert import ( + board_type_from_str, + market_value_from_str, + sort_order_from_str, + sort_type_from_str, +) +from easy_tdx.web.deps import get_mac_client +from easy_tdx.web.schemas import DataFrameResponse, DictResponse + +router = APIRouter(tags=["board-mac"]) + + +def _df_resp(df: Any) -> DataFrameResponse: + return DataFrameResponse.from_dataframe(df) + + +@router.get("/board-mac/list", response_model=DataFrameResponse) +async def board_list( + board_type: str = Query("ALL", description="板块类型: ALL/HY/HY2/GN/FG/DQ"), + count: int = Query(500, ge=1, le=50000), + client: Any = Depends(get_mac_client), +) -> DataFrameResponse: + """获取板块列表。""" + df = await client.get_board_list(board_type=board_type_from_str(board_type), count=count) + return _df_resp(df) + + +@router.get("/board-mac/members", response_model=DataFrameResponse) +async def board_members( + board_symbol: str = Query(..., description="板块代码,如 881001"), + count: int = Query(100, ge=1, le=100000), + sort_type: str = Query("CHANGE_PCT", description="排序字段"), + sort_order: str = Query("DESC", description="排序方向: ASC/DESC"), + client: Any = Depends(get_mac_client), +) -> DataFrameResponse: + """获取板块成分股。""" + df = await client.get_board_members( + board_symbol=board_symbol, + count=count, + sort_type=sort_type_from_str(sort_type), + sort_order=sort_order_from_str(sort_order), + ) + return _df_resp(df) + + +@router.get("/board-mac/belong", response_model=DataFrameResponse) +async def board_belong( + market: str = Query(..., description="市场: SZ, SH"), + code: str = Query(..., min_length=6, max_length=6, description="6位股票代码"), + client: Any = Depends(get_mac_client), +) -> DataFrameResponse: + """获取股票所属板块列表。""" + df = await client.get_belong_board(market=market_value_from_str(market), code=code) + return _df_resp(df) + + +@router.get("/board-mac/summary", response_model=DictResponse) +async def board_summary( + board_symbol: str = Query(..., description="板块代码,如 881001"), + sort_type: str = Query("CHANGE_PCT", description="排序字段"), + sort_order: str = Query("DESC", description="排序方向: ASC/DESC"), + client: Any = Depends(get_mac_client), +) -> DictResponse: + """获取板块摘要信息(含成分股资金流向)。""" + result = await client.get_board_summary( + board_symbol=board_symbol, + sort_type=sort_type_from_str(sort_type), + sort_order=sort_order_from_str(sort_order), + ) + return DictResponse.from_dict(result) + + +@router.get("/board-mac/ranking", response_model=DataFrameResponse) +async def board_ranking( + board_type: str = Query("HY", description="板块类型: HY/HY2/GN/FG/DQ"), + top_n: int = Query(10, ge=1, le=200), + sort_by: str = Query("change_pct", description="排序字段名"), + ascending: bool = Query(False, description="是否升序"), + client: Any = Depends(get_mac_client), +) -> DataFrameResponse: + """获取板块涨幅排名。""" + df = await client.get_board_ranking( + board_type=board_type_from_str(board_type), + top_n=top_n, + sort_by=sort_by, + ascending=ascending, + ) + return _df_resp(df) + + +@router.get("/board-mac/change-ranking", response_model=DataFrameResponse) +async def board_change_ranking( + board_type: str = Query("HY", description="板块类型: HY/HY2/GN/FG/DQ"), + days: int = Query(20, ge=1, le=250, description="统计天数"), + top_n: int = Query(10, ge=1, le=200), + target_date: int | None = Query(None, description="目标日期,如 20250101"), + ascending: bool = Query(False, description="是否升序"), + client: Any = Depends(get_mac_client), +) -> DataFrameResponse: + """获取板块 N 日涨幅排名。""" + df = await client.get_board_change_ranking( + board_type=board_type_from_str(board_type), + target_date=target_date, + days=days, + top_n=top_n, + ascending=ascending, + ) + return _df_resp(df) diff --git a/src/easy_tdx/web/routers/ex_market.py b/src/easy_tdx/web/routers/ex_market.py new file mode 100644 index 0000000..09de9a0 --- /dev/null +++ b/src/easy_tdx/web/routers/ex_market.py @@ -0,0 +1,83 @@ +"""扩展市场路由:期货、港股、美股等扩展市场行情数据。""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Any + +from fastapi import APIRouter, Depends, Query + +from easy_tdx.web.convert import category_from_str, ex_market_from_str +from easy_tdx.web.deps import get_ex_client +from easy_tdx.web.schemas import DataFrameResponse + +router = APIRouter(tags=["ex-market"]) + + +def _records_to_df_resp(records: list[Any]) -> DataFrameResponse: + """将 dataclass 列表转为 DataFrameResponse。""" + import pandas as pd + + if not records: + return DataFrameResponse(data=[], count=0) + df = pd.DataFrame([asdict(r) for r in records]) + return DataFrameResponse.from_dataframe(df) + + +@router.get("/ex/bars", response_model=DataFrameResponse) +async def ex_bars( + market: str = Query(..., description="扩展市场代码,如 HK_MAIN_BOARD 或数字"), + code: str = Query(..., description="合约/证券代码"), + category: str = Query("DAY", description="K线周期: MIN_1/MIN_5/.../DAY/WEEK/MONTH"), + start: int = Query(0, ge=0), + count: int = Query(700, ge=1, le=700), + client: Any = Depends(get_ex_client), +) -> DataFrameResponse: + """获取扩展市场 K 线数据。""" + records = await client.get_instrument_bars( + category=int(category_from_str(category)), + market=ex_market_from_str(market), + code=code, + start=start, + count=count, + ) + return _records_to_df_resp(records) + + +@router.get("/ex/quote", response_model=DataFrameResponse) +async def ex_quote( + market: str = Query(..., description="扩展市场代码"), + code: str = Query(..., description="合约/证券代码"), + client: Any = Depends(get_ex_client), +) -> DataFrameResponse: + """获取扩展市场实时报价。""" + result = await client.get_instrument_quote(market=ex_market_from_str(market), code=code) + if result is None: + return DataFrameResponse(data=[], count=0) + return _records_to_df_resp([result]) + + +@router.get("/ex/minute", response_model=DataFrameResponse) +async def ex_minute( + market: str = Query(..., description="扩展市场代码"), + code: str = Query(..., description="合约/证券代码"), + client: Any = Depends(get_ex_client), +) -> DataFrameResponse: + """获取扩展市场分时数据。""" + records = await client.get_minute_time_data(market=ex_market_from_str(market), code=code) + return _records_to_df_resp(records) + + +@router.get("/ex/transaction", response_model=DataFrameResponse) +async def ex_transaction( + market: str = Query(..., description="扩展市场代码"), + code: str = Query(..., description="合约/证券代码"), + start: int = Query(0, ge=0), + count: int = Query(1800, ge=1, le=3000), + client: Any = Depends(get_ex_client), +) -> DataFrameResponse: + """获取扩展市场逐笔成交数据。""" + records = await client.get_transaction_data( + market=ex_market_from_str(market), code=code, start=start, count=count + ) + return _records_to_df_resp(records) diff --git a/src/easy_tdx/web/routers/indicator.py b/src/easy_tdx/web/routers/indicator.py new file mode 100644 index 0000000..864aee8 --- /dev/null +++ b/src/easy_tdx/web/routers/indicator.py @@ -0,0 +1,45 @@ +"""技术指标路由:指标列表、指标计算。""" + +from __future__ import annotations + +from typing import Any + +import pandas as pd +from fastapi import APIRouter + +from easy_tdx.web.schemas import ComputeIndicatorsRequest, DataFrameResponse + +router = APIRouter(tags=["indicator"]) + + +def _df_resp(df: Any) -> DataFrameResponse: + return DataFrameResponse.from_dataframe(df) + + +@router.get("/indicator/list") +async def indicator_list() -> list[dict[str, Any]]: + """获取所有可用技术指标列表。""" + from easy_tdx.indicator import list_indicators + + return list_indicators() + + +@router.post("/indicator/compute", response_model=DataFrameResponse) +async def indicator_compute( + req: ComputeIndicatorsRequest, +) -> DataFrameResponse: + """在 OHLCV 数据上计算技术指标。 + + 请求体包含 K 线 records 和指标名称列表,返回计算后的 DataFrame。 + """ + from easy_tdx.indicator import compute_indicators + + df = pd.DataFrame(req.data) + result = compute_indicators( + df, + indicators=req.indicators, + params=req.params, + keep_ohlcv=req.keep_ohlcv, + tail=req.tail, + ) + return _df_resp(result) diff --git a/src/easy_tdx/web/routers/mac_data.py b/src/easy_tdx/web/routers/mac_data.py new file mode 100644 index 0000000..7f2648a --- /dev/null +++ b/src/easy_tdx/web/routers/mac_data.py @@ -0,0 +1,48 @@ +"""MAC 数据路由:资金流向、个股信息、服务器信息。""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, Query + +from easy_tdx.web.convert import market_value_from_str +from easy_tdx.web.deps import get_mac_client +from easy_tdx.web.schemas import DataFrameResponse + +router = APIRouter(tags=["mac-data"]) + + +def _df_resp(df: Any) -> DataFrameResponse: + return DataFrameResponse.from_dataframe(df) + + +@router.get("/mac/capital-flow", response_model=DataFrameResponse) +async def capital_flow( + market: str = Query(..., description="市场: SZ, SH"), + code: str = Query(..., min_length=6, max_length=6, description="6位股票代码"), + client: Any = Depends(get_mac_client), +) -> DataFrameResponse: + """获取个股资金流向(主力/散户净流入)。""" + df = await client.get_capital_flow(market=market_value_from_str(market), code=code) + return _df_resp(df) + + +@router.get("/mac/symbol-info", response_model=DataFrameResponse) +async def symbol_info( + market: str = Query(..., description="市场: SZ, SH"), + code: str = Query(..., min_length=6, max_length=6, description="6位股票代码"), + client: Any = Depends(get_mac_client), +) -> DataFrameResponse: + """获取个股基本信息快照。""" + df = await client.get_symbol_info(market=market_value_from_str(market), code=code) + return _df_resp(df) + + +@router.get("/mac/server-info", response_model=DataFrameResponse) +async def server_info( + client: Any = Depends(get_mac_client), +) -> DataFrameResponse: + """获取服务器交易时段信息。""" + df = await client.get_server_info() + return _df_resp(df) diff --git a/src/easy_tdx/web/routers/mac_quotes.py b/src/easy_tdx/web/routers/mac_quotes.py new file mode 100644 index 0000000..cf76be7 --- /dev/null +++ b/src/easy_tdx/web/routers/mac_quotes.py @@ -0,0 +1,69 @@ +"""MAC 行情路由:排行行情列表、竞价数据、异动行情。""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, Query + +from easy_tdx.web.convert import ( + category_mac_from_str, + filter_types_from_str, + market_value_from_str, + sort_order_from_str, + sort_type_from_str, +) +from easy_tdx.web.deps import get_mac_client +from easy_tdx.web.schemas import DataFrameResponse + +router = APIRouter(tags=["mac-quotes"]) + + +def _df_resp(df: Any) -> DataFrameResponse: + return DataFrameResponse.from_dataframe(df) + + +@router.get("/mac/quote-list", response_model=DataFrameResponse) +async def quote_list( + category: str = Query("A", description="市场分类: A/SH/SZ/KCB/BJ/CYB"), + start: int = Query(0, ge=0, description="分页起始位置"), + count: int = Query(80, ge=1, le=5000, description="返回数量"), + sort_type: str = Query("CHANGE_PCT", description="排序字段"), + sort_order: str = Query("DESC", description="排序方向: ASC/DESC"), + exclude: str | None = Query(None, description="过滤标志(逗号分隔): ST,KC,BJ,..."), + client: Any = Depends(get_mac_client), +) -> DataFrameResponse: + """获取分排行行情列表(涨幅/成交量/换手等排序)。""" + exclude_flags = filter_types_from_str(exclude) if exclude else None + df = await client.get_stock_quotes_list( + category=category_mac_from_str(category), + start=start, + count=count, + sort_type=sort_type_from_str(sort_type), + sort_order=sort_order_from_str(sort_order), + exclude_flags=exclude_flags, + ) + return _df_resp(df) + + +@router.get("/mac/auction", response_model=DataFrameResponse) +async def auction( + market: str = Query(..., description="市场: SZ, SH"), + code: str = Query(..., min_length=6, max_length=6, description="6位股票代码"), + client: Any = Depends(get_mac_client), +) -> DataFrameResponse: + """获取集合竞价数据。""" + df = await client.get_auction(market=market_value_from_str(market), code=code) + return _df_resp(df) + + +@router.get("/mac/unusual", response_model=DataFrameResponse) +async def unusual( + market: str = Query(..., description="市场: SZ, SH"), + start: int = Query(0, ge=0, description="分页起始位置"), + count: int = Query(50, ge=1, le=500, description="返回数量"), + client: Any = Depends(get_mac_client), +) -> DataFrameResponse: + """获取市场异动行情数据。""" + df = await client.get_unusual(market=market_value_from_str(market), start=start, count=count) + return _df_resp(df) diff --git a/src/easy_tdx/web/schemas.py b/src/easy_tdx/web/schemas.py index 9fc7377..47e7f2b 100644 --- a/src/easy_tdx/web/schemas.py +++ b/src/easy_tdx/web/schemas.py @@ -65,6 +65,18 @@ class ChanlunRequest(BaseModel): start: int = Field(default=0, ge=0) +class ComputeIndicatorsRequest(BaseModel): + """技术指标计算请求。""" + + data: list[dict[str, Any]] = Field(..., description="OHLCV records") + indicators: list[str] = Field(..., min_length=1, description="指标名称列表") + params: dict[str, dict[str, int | float]] | None = Field( + default=None, description="指标参数(可选)" + ) + keep_ohlcv: bool = Field(default=True, description="保留原始 OHLCV 列") + tail: int | None = Field(default=None, ge=1, description="仅返回末尾 N 行") + + # --------------------------------------------------------------------------- # Response models # --------------------------------------------------------------------------- @@ -87,6 +99,7 @@ class DataFrameResponse(BaseModel): for row in records: clean_row: dict[str, Any] = {} for k, v in row.items(): + assert isinstance(k, str) if hasattr(v, "isoformat"): clean_row[k] = v.isoformat() elif hasattr(v, "item"): @@ -99,6 +112,29 @@ class DataFrameResponse(BaseModel): return cls(data=[], count=0) +class DictResponse(BaseModel): + """通用 dict 响应(用于非 DataFrame 返回值)。""" + + data: dict[str, Any] + + @classmethod + def from_dict(cls, d: dict[str, Any]) -> DictResponse: + """序列化 dict,将其中的 DataFrame 转为 records 格式。""" + import pandas as pd + + cleaned: dict[str, Any] = {} + for k, v in d.items(): + if isinstance(v, pd.DataFrame): + cleaned[k] = DataFrameResponse.from_dataframe(v).data + elif hasattr(v, "isoformat"): + cleaned[k] = v.isoformat() + elif hasattr(v, "item"): + cleaned[k] = v.item() + else: + cleaned[k] = v + return cls(data=cleaned) + + class CountResponse(BaseModel): """简单计数响应。"""