mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 19:14:16 +08:00
feat(web): add THS stock associations API
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
"""同花顺公开网页解析及接口测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("fastapi")
|
||||
|
||||
from fastapi import FastAPI # noqa: E402
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
from easy_tdx.ths_web import ( # noqa: E402
|
||||
ThsWebClient,
|
||||
parse_board_detail,
|
||||
parse_board_directory,
|
||||
parse_concepts,
|
||||
parse_industry_hierarchy,
|
||||
)
|
||||
from easy_tdx.web.deps import get_ths_web_client # noqa: E402
|
||||
from easy_tdx.web.routers.ths import router # noqa: E402
|
||||
|
||||
_FIELD_HTML = """
|
||||
<p class="threecate fl">三级行业分类:
|
||||
<span class="tip f14">电子 -- 半导体 -- 数字芯片设计 (共<strong>57</strong>家)</span></p>
|
||||
"""
|
||||
|
||||
_CONCEPT_HTML = """
|
||||
<table>
|
||||
<tr><td class="gnName" clid="301085">芯片概念</td></tr>
|
||||
<tr><td class="gnName" clid="308972">比亚迪概念</td></tr>
|
||||
</table>
|
||||
<a topStock="002886,003005" cid="301085" tag="芯片概念">芯片概念</a>
|
||||
<a topStock="300750" cid="308972" tag="比亚迪概念">比亚迪概念</a>
|
||||
"""
|
||||
|
||||
_INDUSTRY_DIRECTORY_HTML = """
|
||||
<table>
|
||||
<tr><td><a href="http://q.10jqka.com.cn/thshy/detail/code/881121/">半导体</a></td></tr>
|
||||
</table>
|
||||
"""
|
||||
|
||||
_CONCEPT_DIRECTORY_HTML = """
|
||||
<table><tr><td><a href="http://q.10jqka.com.cn/gn/detail/code/301085/">芯片概念</a></td>
|
||||
<td><a href="http://q.10jqka.com.cn/gn/detail/code/308972/">比亚迪概念</a></td></tr></table>
|
||||
"""
|
||||
|
||||
_INDUSTRY_DETAIL_HTML = """
|
||||
<div class="board-hq"><h3>半导体<span>881121</span></h3>
|
||||
<p class="board-zdf">12.34 2.35%</p></div>
|
||||
<table>
|
||||
<tr>
|
||||
<td>1</td><td><a href="http://stockpage.10jqka.com.cn/688981/">688981</a></td>
|
||||
<td><a href="http://stockpage.10jqka.com.cn/688981/">中芯国际</a></td>
|
||||
<td>50.00</td><td>3.21</td>
|
||||
</tr>
|
||||
</table>
|
||||
"""
|
||||
|
||||
_CHIP_DETAIL_HTML = """
|
||||
<div class="board-hq"><h3>芯片概念<span>301085</span></h3>
|
||||
<p class="board-zdf">-0.01 -0.05%</p></div>
|
||||
<table><tr><td>1</td><td><a href="http://stockpage.10jqka.com.cn/002886/">002886</a></td>
|
||||
<td><a href="http://stockpage.10jqka.com.cn/002886/">沃特股份</a></td><td>31.00</td><td>9.50</td></tr></table>
|
||||
"""
|
||||
|
||||
_BYD_DETAIL_HTML = """
|
||||
<div class="board-hq"><h3>比亚迪概念<span>308972</span></h3>
|
||||
<p class="board-zdf">0.00 0.00%</p></div>
|
||||
"""
|
||||
|
||||
|
||||
def test_parse_industry_hierarchy() -> None:
|
||||
assert parse_industry_hierarchy(_FIELD_HTML) == ["电子", "半导体", "数字芯片设计"]
|
||||
|
||||
|
||||
def test_parse_concepts_extracts_codes_names_and_leading_stock_codes() -> None:
|
||||
assert parse_concepts(_CONCEPT_HTML) == [
|
||||
{"board_code": "301085", "name": "芯片概念", "leader_codes": ["002886", "003005"]},
|
||||
{"board_code": "308972", "name": "比亚迪概念", "leader_codes": ["300750"]},
|
||||
]
|
||||
|
||||
|
||||
def test_parse_board_directory_and_detail_extract_quote_and_leader() -> None:
|
||||
assert parse_board_directory(_INDUSTRY_DIRECTORY_HTML, "industry") == {"半导体": "881121"}
|
||||
assert parse_board_detail(_INDUSTRY_DETAIL_HTML) == {
|
||||
"board_code": "881121",
|
||||
"change_pct": 2.35,
|
||||
"leader": {"code": "688981", "name": "中芯国际", "change_pct": 3.21},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_merges_stock_membership_and_board_quotes() -> None:
|
||||
pages = {
|
||||
"/603893/field.html": _FIELD_HTML,
|
||||
"/603893/concept.html": _CONCEPT_HTML,
|
||||
"/thshy/": _INDUSTRY_DIRECTORY_HTML,
|
||||
"/gn/": _CONCEPT_DIRECTORY_HTML,
|
||||
"/thshy/detail/code/881121/": _INDUSTRY_DETAIL_HTML,
|
||||
"/gn/detail/code/301085/": _CHIP_DETAIL_HTML,
|
||||
"/gn/detail/code/308972/": _BYD_DETAIL_HTML,
|
||||
}
|
||||
calls: dict[str, int] = {}
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls[request.url.path] = calls.get(request.url.path, 0) + 1
|
||||
return httpx.Response(200, content=pages[request.url.path].encode("gbk"))
|
||||
|
||||
client = ThsWebClient(transport=httpx.MockTransport(handler))
|
||||
result = await client.get_stock_associations("603893")
|
||||
|
||||
assert result["code"] == "603893"
|
||||
assert result["industries"] == [
|
||||
{"level": 1, "name": "电子", "board_code": None, "change_pct": None, "leader": None},
|
||||
{
|
||||
"level": 2,
|
||||
"name": "半导体",
|
||||
"board_code": "881121",
|
||||
"change_pct": 2.35,
|
||||
"leader": {"code": "688981", "name": "中芯国际", "change_pct": 3.21},
|
||||
},
|
||||
{
|
||||
"level": 3,
|
||||
"name": "数字芯片设计",
|
||||
"board_code": None,
|
||||
"change_pct": None,
|
||||
"leader": None,
|
||||
},
|
||||
]
|
||||
assert result["concept_total"] == 2
|
||||
assert result["concepts"] == [
|
||||
{
|
||||
"board_code": "301085",
|
||||
"name": "芯片概念",
|
||||
"change_pct": -0.05,
|
||||
"leader": {"code": "002886", "name": "沃特股份", "change_pct": 9.5},
|
||||
"leader_codes": ["002886", "003005"],
|
||||
},
|
||||
{
|
||||
"board_code": "308972",
|
||||
"name": "比亚迪概念",
|
||||
"change_pct": 0.0,
|
||||
"leader": None,
|
||||
"leader_codes": ["300750"],
|
||||
},
|
||||
]
|
||||
await client.get_stock_associations("603893")
|
||||
assert calls == {
|
||||
"/603893/field.html": 2,
|
||||
"/603893/concept.html": 2,
|
||||
"/thshy/": 2,
|
||||
"/gn/": 2,
|
||||
"/thshy/detail/code/881121/": 1,
|
||||
"/gn/detail/code/301085/": 1,
|
||||
"/gn/detail/code/308972/": 1,
|
||||
}
|
||||
|
||||
|
||||
class _FakeThsClient:
|
||||
async def get_stock_associations(self, code: str, concept_limit: int) -> dict[str, object]:
|
||||
assert concept_limit == 10
|
||||
return {"source": "ths_web", "code": code, "industries": [], "concepts": []}
|
||||
|
||||
|
||||
def _api_client() -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/v1")
|
||||
app.dependency_overrides[get_ths_web_client] = lambda: _FakeThsClient()
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_ths_associations_endpoint() -> None:
|
||||
with _api_client() as client:
|
||||
response = client.get("/api/v1/ths/stock/associations", params={"code": "603893"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"data": {"source": "ths_web", "code": "603893", "industries": [], "concepts": []}
|
||||
}
|
||||
|
||||
|
||||
def test_ths_associations_endpoint_validates_code() -> None:
|
||||
with _api_client() as client:
|
||||
response = client.get("/api/v1/ths/stock/associations", params={"code": "60389"})
|
||||
|
||||
assert response.status_code == 422
|
||||
@@ -8,6 +8,25 @@ from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _mounted_paths(app):
|
||||
"""Return routes for both flattened and _IncludedRouter FastAPI versions."""
|
||||
paths: list[str] = []
|
||||
for route in app.routes:
|
||||
if hasattr(route, "path"):
|
||||
paths.append(route.path)
|
||||
continue
|
||||
original_router = getattr(route, "original_router", None)
|
||||
include_context = getattr(route, "include_context", None)
|
||||
if original_router is not None and include_context is not None:
|
||||
paths.extend(
|
||||
f"{include_context.prefix}{child.path}"
|
||||
for child in original_router.routes
|
||||
if hasattr(child, "path")
|
||||
)
|
||||
return paths
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task 2: Schemas & Error Handling
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -79,7 +98,7 @@ def test_create_app_returns_fastapi_instance():
|
||||
assert app.title == "easy-tdx API"
|
||||
|
||||
# Check routers are mounted
|
||||
routes = [r.path for r in app.routes]
|
||||
routes = _mounted_paths(app)
|
||||
assert any("/api/v1/security" in r for r in routes)
|
||||
assert any("/api/v1/bars" in r for r in routes)
|
||||
assert any("/api/v1/chanlun" in r for r in routes)
|
||||
@@ -503,13 +522,14 @@ def test_full_app_routes_registered():
|
||||
from easy_tdx.web import create_app
|
||||
|
||||
app = create_app()
|
||||
all_paths = [r.path for r in app.routes]
|
||||
all_paths = _mounted_paths(app)
|
||||
expected_prefixes = [
|
||||
"/api/v1/security",
|
||||
"/api/v1/bars",
|
||||
"/api/v1/xdxr",
|
||||
"/api/v1/block",
|
||||
"/api/v1/stock/industry",
|
||||
"/api/v1/ths/stock/associations",
|
||||
"/api/v1/chanlun",
|
||||
"/api/v1/announcements",
|
||||
"/api/v1/sina/financial-report",
|
||||
@@ -535,3 +555,4 @@ def test_openapi_schema_generated():
|
||||
# they are verified in test_full_app_routes_registered instead.
|
||||
# Just ensure REST paths are present.
|
||||
assert "/api/v1/fund-flow" in schema["paths"]
|
||||
assert "/api/v1/ths/stock/associations" in schema["paths"]
|
||||
|
||||
Reference in New Issue
Block a user