From 8adb1ff357c30a01ba1e14837f8426b94f9b06ed Mon Sep 17 00:00:00 2001 From: yanwei99521 Date: Mon, 31 Aug 2026 16:26:27 +0800 Subject: [PATCH] feat(web): add stock industry API --- README.md | 3 + docs/api_reference.md | 51 +++++++ src/easy_tdx/web/app.py | 2 + src/easy_tdx/web/routers/stock_industry.py | 81 ++++++++++ tests/unit/test_web_api.py | 1 + tests/unit/test_web_stock_industry.py | 169 +++++++++++++++++++++ 6 files changed, 307 insertions(+) create mode 100644 src/easy_tdx/web/routers/stock_industry.py create mode 100644 tests/unit/test_web_stock_industry.py diff --git a/README.md b/README.md index 6937a57..7b94906 100644 --- a/README.md +++ b/README.md @@ -1048,6 +1048,9 @@ curl "http://localhost:8000/api/v1/board-mac/members?board_symbol=881001&count=2 # 个股所属板块 curl "http://localhost:8000/api/v1/board-mac/belong?market=SZ&code=000001" +# 个股所属行业及行业今日涨跌幅 +curl "http://localhost:8000/api/v1/stock/industry?market=SZ&code=000001" + # 板块摘要(含主力净流入、涨跌家数) curl "http://localhost:8000/api/v1/board-mac/summary?board_symbol=881001" diff --git a/docs/api_reference.md b/docs/api_reference.md index fd4d24f..db6a573 100644 --- a/docs/api_reference.md +++ b/docs/api_reference.md @@ -15,6 +15,7 @@ - [逐笔成交](#逐笔成交) - [财务与公司信息](#财务与公司信息) - [板块信息](#板块信息) +- [Web API:股票所属行业](#web-api股票所属行业) - [资金流向](#资金流向) - [文件下载](#文件下载) - [市场统计](#市场统计) @@ -324,6 +325,56 @@ c.get_block_info(filename: str) -> list[TdxBlock] --- +## Web API:股票所属行业 + +### GET `/api/v1/stock/industry` + +获取指定股票所属的行业,以及每个行业板块的当日涨跌幅。该接口属于 Web API,启动服务后可在 +`/docs`(Swagger UI)或 `/redoc`(ReDoc)中在线调试。 + +**请求参数**: + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `market` | `string` | 是 | 市场代码:`SZ`(深市)、`SH`(沪市)、`BJ`(北交所) | +| `code` | `string` | 是 | 6 位股票代码,如 `000001`、`600519` | + +**请求示例**: + +```bash +curl "http://localhost:8000/api/v1/stock/industry?market=SH&code=600519" +``` + +**响应示例**: + +```json +{ + "data": [ + { + "market": "SH", + "code": "600519", + "industry_code": "881130", + "industry_name": "酿酒", + "board_type": 12, + "close": 577.81, + "pre_close": 579.42, + "change_pct": -0.28 + } + ], + "count": 1 +} +``` + +字段说明:`industry_code` 和 `industry_name` 是行业板块代码和名称;`close`、`pre_close` +分别是行业板块当前收盘价和昨收价;`change_pct` 是按 +`(close - pre_close) / pre_close × 100` 计算的当日涨跌幅(百分比,保留两位小数)。 +一只股票可能返回多个行业层级,因此 `data` 可能包含多条记录。 + +行业归属和行业行情每次请求都从 MAC 行情服务器实时获取,不使用本地缓存,避免跨交易日复用过期的 +行业关系或涨跌幅数据。若行情服务器返回的昨收价为 0,`change_pct` 将返回 `null`。 + +--- + ## 资金流向 ### get_fund_flow diff --git a/src/easy_tdx/web/app.py b/src/easy_tdx/web/app.py index 8f3e8ed..99bd2a0 100644 --- a/src/easy_tdx/web/app.py +++ b/src/easy_tdx/web/app.py @@ -213,6 +213,7 @@ def _create_app( 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.stock_industry import router as stock_industry_router from easy_tdx.web.routers.strategies import router as strategies_router app.include_router(market_router, prefix="/api/v1") @@ -225,6 +226,7 @@ def _create_app( 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(stock_industry_router, prefix="/api/v1") # 扩展市场路由 app.include_router(ex_market_router, prefix="/api/v1") # 技术指标路由 diff --git a/src/easy_tdx/web/routers/stock_industry.py b/src/easy_tdx/web/routers/stock_industry.py new file mode 100644 index 0000000..a239d15 --- /dev/null +++ b/src/easy_tdx/web/routers/stock_industry.py @@ -0,0 +1,81 @@ +"""指定股票所属行业及行业当日涨跌幅路由。""" + +from __future__ import annotations + +import math +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=["stock-industry"]) + +# ``get_belong_board`` 的 board_type 来自行情服务器的所属板块响应, +# 与 BoardType(用于板块列表/排行请求)的编码并非始终相同。 +# 0/1 是标准协议中的行业一级/二级,部分 MAC 服务器将行业归属返回为 12。 +_INDUSTRY_BOARD_TYPES = frozenset({0, 1, 12}) + + +def _finite_float(value: object) -> float | None: + """Convert a value to a finite float, treating missing values as None.""" + try: + result = float(value) # type: ignore[arg-type] + except (TypeError, ValueError): + return None + return result if math.isfinite(result) else None + + +def _industry_rows(df: Any, market: str, code: str) -> list[dict[str, Any]]: + """Filter belonging-board rows to industries and calculate today's change.""" + if df is None or getattr(df, "empty", True): + return [] + + rows: list[dict[str, Any]] = [] + for row in df.to_dict(orient="records"): + try: + board_type = int(row.get("board_type", -1)) + except (TypeError, ValueError): + continue + if board_type not in _INDUSTRY_BOARD_TYPES: + continue + + close = _finite_float(row.get("close")) + pre_close = _finite_float(row.get("pre_close")) + change_pct = ( + round((close - pre_close) / pre_close * 100, 2) + if close is not None and pre_close not in (None, 0) + else None + ) + rows.append( + { + "market": market, + "code": code, + "industry_code": str(row.get("board_code", "")), + "industry_name": str(row.get("board_name", "")), + "board_type": board_type, + "close": close, + "pre_close": pre_close, + "change_pct": change_pct, + } + ) + return rows + + +@router.get("/stock/industry", response_model=DataFrameResponse) +async def stock_industry( + market: str = Query(..., description="市场: SZ, SH, BJ"), + code: str = Query(..., min_length=6, max_length=6, description="6位股票代码"), + client: Any = Depends(get_mac_client), +) -> DataFrameResponse: + """获取指定股票所属行业及行业今日涨跌幅。 + + 行业归属和行业指数的收盘/昨收均从 MAC 行情服务器实时读取,不使用本地缓存, + 因此不会跨交易日复用过期的行业关系或行情数据。 + """ + market_code = market.upper() + df = await client.get_belong_board(market=market_value_from_str(market_code), code=code) + rows = _industry_rows(df, market_code, code) + return DataFrameResponse(data=rows, count=len(rows)) diff --git a/tests/unit/test_web_api.py b/tests/unit/test_web_api.py index 78f173e..9e23302 100644 --- a/tests/unit/test_web_api.py +++ b/tests/unit/test_web_api.py @@ -509,6 +509,7 @@ def test_full_app_routes_registered(): "/api/v1/bars", "/api/v1/xdxr", "/api/v1/block", + "/api/v1/stock/industry", "/api/v1/chanlun", "/api/v1/announcements", "/api/v1/sina/financial-report", diff --git a/tests/unit/test_web_stock_industry.py b/tests/unit/test_web_stock_industry.py new file mode 100644 index 0000000..2761488 --- /dev/null +++ b/tests/unit/test_web_stock_industry.py @@ -0,0 +1,169 @@ +"""股票所属行业接口测试(离线,使用假的 MAC 客户端)。""" + +from __future__ import annotations + +import pandas as pd +import pytest + +pytest.importorskip("fastapi") + +from fastapi import FastAPI # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 + +from easy_tdx.web.routers.stock_industry import router # noqa: E402 + + +class _FakeMacClient: + def __init__(self, rows: list[dict[str, object]]) -> None: + self.rows = rows + self.calls: list[tuple[int, str]] = [] + + async def get_belong_board(self, market: int, code: str) -> pd.DataFrame: + self.calls.append((market, code)) + return pd.DataFrame(self.rows) + + +def _client(fake: _FakeMacClient) -> TestClient: + app = FastAPI() + app.include_router(router, prefix="/api/v1") + app.state.mac_client = fake + return TestClient(app) + + +def test_stock_industry_returns_industries_and_daily_change_pct() -> None: + fake = _FakeMacClient( + [ + { + "board_type": 12, + "board_code": "881130", + "board_name": "酿酒", + "close": 110.0, + "pre_close": 100.0, + }, + { + "board_type": 5, + "board_code": "880821", + "board_name": "大盘股", + "close": 105.0, + "pre_close": 100.0, + }, + { + "board_type": 0, + "board_code": "881200", + "board_name": "食品饮料", + "close": 99.0, + "pre_close": 100.0, + }, + ] + ) + with _client(fake) as client: + response = client.get( + "/api/v1/stock/industry", + params={"market": "SH", "code": "600519"}, + ) + + assert response.status_code == 200 + body = response.json() + assert body["count"] == 2 + assert body["data"] == [ + { + "market": "SH", + "code": "600519", + "industry_code": "881130", + "industry_name": "酿酒", + "board_type": 12, + "close": 110.0, + "pre_close": 100.0, + "change_pct": 10.0, + }, + { + "market": "SH", + "code": "600519", + "industry_code": "881200", + "industry_name": "食品饮料", + "board_type": 0, + "close": 99.0, + "pre_close": 100.0, + "change_pct": -1.0, + }, + ] + assert fake.calls == [(1, "600519")] + + +def test_stock_industry_handles_zero_pre_close_and_empty_result() -> None: + fake = _FakeMacClient( + [ + { + "board_type": 1, + "board_code": "881201", + "board_name": "食品饮料二级", + "close": 99.0, + "pre_close": 0.0, + }, + { + "board_type": 4, + "board_code": "880564", + "board_name": "白酒概念", + "close": 110.0, + "pre_close": 100.0, + }, + ] + ) + with _client(fake) as client: + response = client.get( + "/api/v1/stock/industry", + params={"market": "SZ", "code": "000001"}, + ) + + assert response.status_code == 200 + assert response.json() == { + "data": [ + { + "market": "SZ", + "code": "000001", + "industry_code": "881201", + "industry_name": "食品饮料二级", + "board_type": 1, + "close": 99.0, + "pre_close": 0.0, + "change_pct": None, + } + ], + "count": 1, + } + + +def test_stock_industry_validates_code_length() -> None: + fake = _FakeMacClient([]) + with _client(fake) as client: + response = client.get( + "/api/v1/stock/industry", + params={"market": "SH", "code": "60051"}, + ) + + assert response.status_code == 422 + assert fake.calls == [] + + +def test_stock_industry_fetches_membership_on_each_request() -> None: + """行业归属不应复用跨请求缓存,交易日变化时可获得最新数据。""" + fake = _FakeMacClient( + [ + { + "board_type": 12, + "board_code": "881130", + "board_name": "酿酒", + "close": 110.0, + "pre_close": 100.0, + } + ] + ) + with _client(fake) as client: + params = {"market": "SH", "code": "600519"} + first = client.get("/api/v1/stock/industry", params=params) + second = client.get("/api/v1/stock/industry", params=params) + + assert first.status_code == 200 + assert second.status_code == 200 + + assert fake.calls == [(1, "600519"), (1, "600519")]