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
@@ -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)