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
|
||||
|
||||
@@ -2,10 +2,14 @@ from dataclasses import replace
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from zhixing_server.modules.sector_radar.application.read import (
|
||||
RadarQuery,
|
||||
RadarView,
|
||||
ReadSectorRadar,
|
||||
SectorRef,
|
||||
StockSectorQuery,
|
||||
)
|
||||
from zhixing_server.modules.sector_radar.domain.metrics import AmountNetStrategy
|
||||
from zhixing_server.modules.sector_radar.domain.models import (
|
||||
@@ -20,7 +24,10 @@ from zhixing_server.modules.sector_radar.domain.models import (
|
||||
RankSide,
|
||||
SectorType,
|
||||
)
|
||||
from zhixing_server.modules.sector_radar.domain.persistence import RankingRecord
|
||||
from zhixing_server.modules.sector_radar.domain.persistence import (
|
||||
MembershipRecord,
|
||||
RankingRecord,
|
||||
)
|
||||
from zhixing_server.modules.sector_radar.domain.ranking import rank_metric_observations
|
||||
from zhixing_server.modules.sector_radar.infrastructure.memory import (
|
||||
InMemorySectorRadarRepository,
|
||||
@@ -187,3 +194,124 @@ def test_latest_partial_attempt_is_visible_but_does_not_replace_last_good() -> N
|
||||
assert index.last_good is not None
|
||||
assert index.last_good.publication_id == "publication-success"
|
||||
assert index.available_dates == (TARGET_DATE,)
|
||||
|
||||
|
||||
def _membership_record(
|
||||
stock_code: str,
|
||||
sector_type: SectorType,
|
||||
sector_code: str,
|
||||
sector_name: str,
|
||||
*,
|
||||
trade_date: date = TARGET_DATE,
|
||||
) -> MembershipRecord:
|
||||
return MembershipRecord(
|
||||
source_snapshot_id="b" * 64,
|
||||
trade_date=trade_date,
|
||||
sector_type=sector_type,
|
||||
sector_code=sector_code,
|
||||
sector_name=sector_name,
|
||||
stock_code=stock_code,
|
||||
stock_name="测试股票",
|
||||
)
|
||||
|
||||
|
||||
def _membership_repository() -> InMemorySectorRadarRepository:
|
||||
repository = _published_repository()
|
||||
repository.save_memberships(
|
||||
(
|
||||
_membership_record("000001.SZ", SectorType.INDUSTRY, "BK0902.DC", "房地产"),
|
||||
_membership_record("000001.SZ", SectorType.CONCEPT, "BK0003.DC", "数字经济"),
|
||||
_membership_record("000001.SZ", SectorType.CONCEPT, "BK0001.DC", "机器人"),
|
||||
_membership_record("000001.SZ", SectorType.CONCEPT, "BK0002.DC", "人工智能"),
|
||||
_membership_record("000002.SZ", SectorType.INDUSTRY, "BK0901.DC", "银行"),
|
||||
)
|
||||
)
|
||||
return repository
|
||||
|
||||
|
||||
def test_stock_membership_groups_types_and_orders_by_sector_code() -> None:
|
||||
reader = ReadSectorRadar(_membership_repository())
|
||||
|
||||
membership = reader.stock_membership(
|
||||
StockSectorQuery(trade_date=TARGET_DATE, ts_code="000001.SZ")
|
||||
)
|
||||
|
||||
assert membership.status == "success"
|
||||
assert membership.trade_date == TARGET_DATE
|
||||
assert membership.industries == (SectorRef(code="BK0902.DC", name="房地产"),)
|
||||
assert [ref.code for ref in membership.concepts] == [
|
||||
"BK0001.DC",
|
||||
"BK0002.DC",
|
||||
"BK0003.DC",
|
||||
]
|
||||
assert membership.concept_total == 3
|
||||
other = reader.stock_membership(StockSectorQuery(trade_date=TARGET_DATE, ts_code="000002.SZ"))
|
||||
assert other.industries == (SectorRef(code="BK0901.DC", name="银行"),)
|
||||
assert other.concepts == ()
|
||||
assert other.concept_total == 0
|
||||
|
||||
|
||||
def test_stock_membership_truncates_concepts_with_total_preserved() -> None:
|
||||
reader = ReadSectorRadar(_membership_repository())
|
||||
|
||||
membership = reader.stock_membership(
|
||||
StockSectorQuery(trade_date=TARGET_DATE, ts_code="000001.SZ", concept_limit=2)
|
||||
)
|
||||
|
||||
assert [ref.code for ref in membership.concepts] == ["BK0001.DC", "BK0002.DC"]
|
||||
assert membership.concept_total == 3
|
||||
|
||||
|
||||
def test_stock_membership_uses_newest_build_at_or_before_requested_date() -> None:
|
||||
repository = _membership_repository()
|
||||
earlier = _running("publication-earlier", TARGET_DATE - timedelta(days=1))
|
||||
repository.create_publication(earlier)
|
||||
repository.finish_publication(_finish(earlier, PublicationStatus.SUCCESS))
|
||||
repository.save_memberships(
|
||||
(
|
||||
_membership_record(
|
||||
"000001.SZ",
|
||||
SectorType.INDUSTRY,
|
||||
"BK0899.DC",
|
||||
"旧行业",
|
||||
trade_date=TARGET_DATE - timedelta(days=1),
|
||||
),
|
||||
)
|
||||
)
|
||||
reader = ReadSectorRadar(repository)
|
||||
|
||||
membership = reader.stock_membership(
|
||||
StockSectorQuery(trade_date=TARGET_DATE, ts_code="000001.SZ")
|
||||
)
|
||||
|
||||
assert membership.trade_date == TARGET_DATE
|
||||
assert membership.industries == (SectorRef(code="BK0902.DC", name="房地产"),)
|
||||
behind = reader.stock_membership(
|
||||
StockSectorQuery(trade_date=TARGET_DATE - timedelta(days=1), ts_code="000001.SZ")
|
||||
)
|
||||
assert behind.trade_date == TARGET_DATE - timedelta(days=1)
|
||||
assert behind.industries == (SectorRef(code="BK0899.DC", name="旧行业"),)
|
||||
assert behind.concepts == ()
|
||||
|
||||
|
||||
def test_stock_membership_without_any_publication_is_no_data() -> None:
|
||||
reader = ReadSectorRadar(InMemorySectorRadarRepository())
|
||||
|
||||
membership = reader.stock_membership(
|
||||
StockSectorQuery(trade_date=TARGET_DATE, ts_code="000001.SZ")
|
||||
)
|
||||
|
||||
assert membership.status == "no_data"
|
||||
assert membership.trade_date is None
|
||||
assert membership.industries == ()
|
||||
assert membership.concepts == ()
|
||||
assert membership.concept_total == 0
|
||||
|
||||
|
||||
def test_stock_membership_query_rejects_invalid_values() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
StockSectorQuery(trade_date=TARGET_DATE, ts_code=" ")
|
||||
with pytest.raises(ValueError):
|
||||
StockSectorQuery(trade_date=TARGET_DATE, ts_code="x" * 13)
|
||||
with pytest.raises(ValueError):
|
||||
StockSectorQuery(trade_date=TARGET_DATE, ts_code="000001.SZ", concept_limit=0)
|
||||
|
||||
Reference in New Issue
Block a user