mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 13:24:15 +08:00
feat(web): add stock industry API
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
# 技术指标路由
|
||||
|
||||
@@ -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))
|
||||
@@ -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",
|
||||
|
||||
@@ -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")]
|
||||
Reference in New Issue
Block a user