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:
yuxuanhui
2026-09-05 16:23:50 +08:00
parent 50cac3575a
commit 41a9b4eb9a
14 changed files with 825 additions and 5 deletions
@@ -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