feat(selection): show stock industry and concept boards in detail panel
Add point-in-time stock membership lookup to the sector radar module
(GET /sector-radar/stocks/{ts_code}/membership) reading the existing
dc_index/dc_member snapshots, and surface industries plus concept chips
in the selection signal detail panel with graceful no-data hiding.
This commit is contained in:
@@ -13,6 +13,9 @@ from zhixing_server.modules.sector_radar.application.read import (
|
||||
RadarQuery,
|
||||
RadarView,
|
||||
RankingPage,
|
||||
SectorRef,
|
||||
StockSectorMembership,
|
||||
StockSectorQuery,
|
||||
)
|
||||
from zhixing_server.modules.sector_radar.domain.metrics import AmountNetStrategy
|
||||
from zhixing_server.modules.sector_radar.domain.models import (
|
||||
@@ -87,7 +90,9 @@ def _ranking() -> RankedMetric:
|
||||
class FakeReader:
|
||||
def __init__(self, *, no_data: bool = False, fail: bool = False) -> None:
|
||||
self.fail = fail
|
||||
self.no_data = no_data
|
||||
self.last_query: RadarQuery | None = None
|
||||
self.last_membership_query: StockSectorQuery | None = None
|
||||
success = _publication("publication-success", PublicationStatus.SUCCESS)
|
||||
current = _publication(
|
||||
"publication-partial",
|
||||
@@ -125,6 +130,32 @@ class FakeReader:
|
||||
self.last_query = query
|
||||
return replace(self.page, query=query)
|
||||
|
||||
def stock_membership(self, query: StockSectorQuery) -> StockSectorMembership:
|
||||
if self.fail:
|
||||
raise SectorRadarRepositoryError("private database detail")
|
||||
self.last_membership_query = query
|
||||
if self.no_data:
|
||||
return StockSectorMembership(
|
||||
status="no_data",
|
||||
query=query,
|
||||
trade_date=None,
|
||||
industries=(),
|
||||
concepts=(),
|
||||
concept_total=0,
|
||||
)
|
||||
return StockSectorMembership(
|
||||
status="success",
|
||||
query=query,
|
||||
trade_date=TARGET_DATE,
|
||||
industries=(SectorRef(code="BK0901.DC", name="银行"),),
|
||||
concepts=(
|
||||
SectorRef(code="BK0001.DC", name="机器人"),
|
||||
SectorRef(code="BK0002.DC", name="人工智能"),
|
||||
SectorRef(code="BK0003.DC", name="数字经济"),
|
||||
),
|
||||
concept_total=3,
|
||||
)
|
||||
|
||||
|
||||
def _client(reader: FakeReader) -> TestClient:
|
||||
application = create_app()
|
||||
@@ -228,3 +259,78 @@ def test_repository_error_maps_to_redacted_503() -> None:
|
||||
}
|
||||
}
|
||||
assert "private database detail" not in response.text
|
||||
|
||||
|
||||
def test_stock_membership_maps_industries_and_concepts() -> None:
|
||||
reader = FakeReader()
|
||||
response = _client(reader).get(
|
||||
"/api/v1/sector-radar/stocks/000001.SZ/membership",
|
||||
params={"trade_date": "2026-08-28"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert reader.last_membership_query == StockSectorQuery(
|
||||
trade_date=TARGET_DATE,
|
||||
ts_code="000001.SZ",
|
||||
)
|
||||
payload = response.json()
|
||||
assert payload["status"] == "success"
|
||||
assert payload["ts_code"] == "000001.SZ"
|
||||
assert payload["requested_trade_date"] == "2026-08-28"
|
||||
assert payload["trade_date"] == "2026-08-28"
|
||||
assert payload["industries"] == [{"code": "BK0901.DC", "name": "银行"}]
|
||||
assert payload["concept_total"] == 3
|
||||
assert payload["concept_limit"] == 20
|
||||
assert [concept["code"] for concept in payload["concepts"]] == [
|
||||
"BK0001.DC",
|
||||
"BK0002.DC",
|
||||
"BK0003.DC",
|
||||
]
|
||||
|
||||
|
||||
def test_stock_membership_no_data_is_a_stable_200_response() -> None:
|
||||
response = _client(FakeReader(no_data=True)).get(
|
||||
"/api/v1/sector-radar/stocks/000001.SZ/membership",
|
||||
params={"trade_date": "2026-08-28"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["status"] == "no_data"
|
||||
assert payload["trade_date"] is None
|
||||
assert payload["industries"] == []
|
||||
assert payload["concepts"] == []
|
||||
assert payload["concept_total"] == 0
|
||||
|
||||
|
||||
def test_stock_membership_rejects_invalid_query_values() -> None:
|
||||
client = _client(FakeReader())
|
||||
|
||||
missing_date = client.get("/api/v1/sector-radar/stocks/000001.SZ/membership")
|
||||
bad_limit = client.get(
|
||||
"/api/v1/sector-radar/stocks/000001.SZ/membership",
|
||||
params={"trade_date": "2026-08-28", "concept_limit": 0},
|
||||
)
|
||||
over_limit = client.get(
|
||||
"/api/v1/sector-radar/stocks/000001.SZ/membership",
|
||||
params={"trade_date": "2026-08-28", "concept_limit": 101},
|
||||
)
|
||||
empty_code = client.get(
|
||||
"/api/v1/sector-radar/stocks//membership", params={"trade_date": "2026-08-28"}
|
||||
)
|
||||
|
||||
assert missing_date.status_code == 422
|
||||
assert bad_limit.status_code == 422
|
||||
assert over_limit.status_code == 422
|
||||
assert empty_code.status_code in (404, 422)
|
||||
|
||||
|
||||
def test_stock_membership_repository_error_maps_to_redacted_503() -> None:
|
||||
response = _client(FakeReader(fail=True)).get(
|
||||
"/api/v1/sector-radar/stocks/000001.SZ/membership",
|
||||
params={"trade_date": "2026-08-28"},
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json()["detail"]["code"] == "sector_radar_storage_unavailable"
|
||||
assert "private database detail" not in response.text
|
||||
|
||||
Reference in New Issue
Block a user