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:
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
@@ -20,11 +21,13 @@ from ..domain.models import (
|
|||||||
RankSide,
|
RankSide,
|
||||||
SectorType,
|
SectorType,
|
||||||
)
|
)
|
||||||
from ..domain.persistence import SectorRadarRepository
|
from ..domain.persistence import SectorRadarRepository, StockMembershipEntry
|
||||||
from ..domain.ranking import select_percentile_side, select_rank_change_side
|
from ..domain.ranking import select_percentile_side, select_rank_change_side
|
||||||
|
|
||||||
ReadStatus = Literal["success", "no_data"]
|
ReadStatus = Literal["success", "no_data"]
|
||||||
|
|
||||||
|
STOCK_SECTOR_CONCEPT_LIMIT = 20
|
||||||
|
|
||||||
|
|
||||||
class RadarView(StrEnum):
|
class RadarView(StrEnum):
|
||||||
"""Supported ranking projections at the HTTP boundary."""
|
"""Supported ranking projections at the HTTP boundary."""
|
||||||
@@ -101,6 +104,50 @@ class RankingPage:
|
|||||||
total: int
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SectorRef:
|
||||||
|
"""Public identity of one sector that claimed a stock."""
|
||||||
|
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class StockSectorQuery:
|
||||||
|
"""Validated application query for one stock's point-in-time membership."""
|
||||||
|
|
||||||
|
trade_date: date
|
||||||
|
ts_code: str
|
||||||
|
concept_limit: int = STOCK_SECTOR_CONCEPT_LIMIT
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
"""Reject empty stock codes and non-positive concept limits."""
|
||||||
|
|
||||||
|
if not self.ts_code.strip():
|
||||||
|
raise ValueError("ts_code must not be empty")
|
||||||
|
if len(self.ts_code) > 12:
|
||||||
|
raise ValueError("ts_code must not exceed 12 characters")
|
||||||
|
if self.concept_limit < 1:
|
||||||
|
raise ValueError("concept_limit must be at least 1")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class StockSectorMembership:
|
||||||
|
"""One stock's industries and concepts as observed on one snapshot date.
|
||||||
|
|
||||||
|
``trade_date`` is the actual membership snapshot date, which is the newest
|
||||||
|
successful build at or before the requested date; it must never be
|
||||||
|
substituted with a newer snapshot.
|
||||||
|
"""
|
||||||
|
|
||||||
|
status: ReadStatus
|
||||||
|
query: StockSectorQuery
|
||||||
|
trade_date: date | None
|
||||||
|
industries: tuple[SectorRef, ...]
|
||||||
|
concepts: tuple[SectorRef, ...]
|
||||||
|
concept_total: int
|
||||||
|
|
||||||
|
|
||||||
_METRIC_DEFINITIONS = {
|
_METRIC_DEFINITIONS = {
|
||||||
MetricKind.AMOUNT: RadarMetricDefinition(
|
MetricKind.AMOUNT: RadarMetricDefinition(
|
||||||
metric_kind=MetricKind.AMOUNT,
|
metric_kind=MetricKind.AMOUNT,
|
||||||
@@ -212,6 +259,48 @@ class ReadSectorRadar:
|
|||||||
total=len(searched),
|
total=len(searched),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def stock_membership(self, query: StockSectorQuery) -> StockSectorMembership:
|
||||||
|
"""Return one stock's sectors from the strict last-good build snapshot."""
|
||||||
|
|
||||||
|
publication = self.repository.get_last_good_publication(query.trade_date)
|
||||||
|
if publication is None:
|
||||||
|
return StockSectorMembership(
|
||||||
|
status="no_data",
|
||||||
|
query=query,
|
||||||
|
trade_date=None,
|
||||||
|
industries=(),
|
||||||
|
concepts=(),
|
||||||
|
concept_total=0,
|
||||||
|
)
|
||||||
|
entries = self.repository.load_stock_memberships(
|
||||||
|
publication.target_trade_date, query.ts_code
|
||||||
|
)
|
||||||
|
industries = _sector_refs(
|
||||||
|
entry for entry in entries if entry.sector_type is SectorType.INDUSTRY
|
||||||
|
)
|
||||||
|
concepts = _sector_refs(
|
||||||
|
entry for entry in entries if entry.sector_type is SectorType.CONCEPT
|
||||||
|
)
|
||||||
|
return StockSectorMembership(
|
||||||
|
status="success",
|
||||||
|
query=query,
|
||||||
|
trade_date=publication.target_trade_date,
|
||||||
|
industries=industries,
|
||||||
|
concepts=concepts[: query.concept_limit],
|
||||||
|
concept_total=len(concepts),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sector_refs(entries: Iterator[StockMembershipEntry]) -> tuple[SectorRef, ...]:
|
||||||
|
"""Project membership entries into ordered public sector references."""
|
||||||
|
|
||||||
|
return tuple(
|
||||||
|
sorted(
|
||||||
|
(SectorRef(code=entry.sector_code, name=entry.sector_name) for entry in entries),
|
||||||
|
key=lambda ref: (ref.code, ref.name),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"RadarDateIndex",
|
"RadarDateIndex",
|
||||||
@@ -220,4 +309,8 @@ __all__ = [
|
|||||||
"RadarView",
|
"RadarView",
|
||||||
"RankingPage",
|
"RankingPage",
|
||||||
"ReadSectorRadar",
|
"ReadSectorRadar",
|
||||||
|
"SectorRef",
|
||||||
|
"StockSectorMembership",
|
||||||
|
"StockSectorQuery",
|
||||||
|
"STOCK_SECTOR_CONCEPT_LIMIT",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -65,6 +65,22 @@ class MembershipRecord:
|
|||||||
return self.stock_code if self.stock_code is not None else "__membership_unknown__"
|
return self.stock_code if self.stock_code is not None else "__membership_unknown__"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class StockMembershipEntry:
|
||||||
|
"""One sector that claimed a stock on exactly one snapshot trade date."""
|
||||||
|
|
||||||
|
trade_date: date
|
||||||
|
sector_type: SectorType
|
||||||
|
sector_code: str
|
||||||
|
sector_name: str
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
"""Reject empty sector identity fields."""
|
||||||
|
|
||||||
|
if not self.sector_code.strip() or not self.sector_name.strip():
|
||||||
|
raise ValueError("membership sector identity fields must not be empty")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class StockFactRecord:
|
class StockFactRecord:
|
||||||
"""One normalized stock fact revision with all contributing raw snapshots."""
|
"""One normalized stock fact revision with all contributing raw snapshots."""
|
||||||
@@ -197,6 +213,10 @@ class SectorRadarRepository(Protocol):
|
|||||||
|
|
||||||
def save_memberships(self, records: Iterable[MembershipRecord]) -> WriteCounts: ...
|
def save_memberships(self, records: Iterable[MembershipRecord]) -> WriteCounts: ...
|
||||||
|
|
||||||
|
def load_stock_memberships(
|
||||||
|
self, trade_date: date, stock_code: str
|
||||||
|
) -> Sequence[StockMembershipEntry]: ...
|
||||||
|
|
||||||
def save_stock_facts(self, records: Iterable[StockFactRecord]) -> WriteCounts: ...
|
def save_stock_facts(self, records: Iterable[StockFactRecord]) -> WriteCounts: ...
|
||||||
|
|
||||||
def save_daily_aggregates(self, records: Iterable[DailyAggregateRecord]) -> WriteCounts: ...
|
def save_daily_aggregates(self, records: Iterable[DailyAggregateRecord]) -> WriteCounts: ...
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from ..domain.persistence import (
|
|||||||
PublicationSourceRecord,
|
PublicationSourceRecord,
|
||||||
RankingRecord,
|
RankingRecord,
|
||||||
StockFactRecord,
|
StockFactRecord,
|
||||||
|
StockMembershipEntry,
|
||||||
WriteCounts,
|
WriteCounts,
|
||||||
)
|
)
|
||||||
from ..domain.source import SourceSnapshot
|
from ..domain.source import SourceSnapshot
|
||||||
@@ -115,6 +116,27 @@ class InMemorySectorRadarRepository:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def load_stock_memberships(
|
||||||
|
self, trade_date: date, stock_code: str
|
||||||
|
) -> Sequence[StockMembershipEntry]:
|
||||||
|
"""Return available sectors that claimed one stock on one snapshot date."""
|
||||||
|
|
||||||
|
return tuple(
|
||||||
|
StockMembershipEntry(
|
||||||
|
trade_date=record.trade_date,
|
||||||
|
sector_type=record.sector_type,
|
||||||
|
sector_code=record.sector_code,
|
||||||
|
sector_name=record.sector_name,
|
||||||
|
)
|
||||||
|
for record in sorted(
|
||||||
|
self.memberships.values(),
|
||||||
|
key=lambda item: (item.sector_type, item.sector_code),
|
||||||
|
)
|
||||||
|
if record.trade_date == trade_date
|
||||||
|
and record.stock_code == stock_code
|
||||||
|
and record.status.value == "available"
|
||||||
|
)
|
||||||
|
|
||||||
def save_stock_facts(self, records: Iterable[StockFactRecord]) -> WriteCounts:
|
def save_stock_facts(self, records: Iterable[StockFactRecord]) -> WriteCounts:
|
||||||
"""Insert normalized fact revisions idempotently."""
|
"""Insert normalized fact revisions idempotently."""
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from ..domain.persistence import (
|
|||||||
PublicationSourceRecord,
|
PublicationSourceRecord,
|
||||||
RankingRecord,
|
RankingRecord,
|
||||||
StockFactRecord,
|
StockFactRecord,
|
||||||
|
StockMembershipEntry,
|
||||||
WriteCounts,
|
WriteCounts,
|
||||||
)
|
)
|
||||||
from ..domain.source import SourceSnapshot
|
from ..domain.source import SourceSnapshot
|
||||||
@@ -283,6 +284,32 @@ class PostgresSectorRadarRepository:
|
|||||||
rows,
|
rows,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def load_stock_memberships(
|
||||||
|
self, trade_date: date, stock_code: str
|
||||||
|
) -> Sequence[StockMembershipEntry]:
|
||||||
|
"""Load every sector that claimed one stock on exactly one snapshot date."""
|
||||||
|
|
||||||
|
with self._connection() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT trade_date, sector_type, sector_code, sector_name
|
||||||
|
FROM sector_radar_membership
|
||||||
|
WHERE trade_date = %s AND stock_code = %s
|
||||||
|
AND membership_status = 'available'
|
||||||
|
ORDER BY sector_type, sector_code
|
||||||
|
""",
|
||||||
|
(trade_date, stock_code),
|
||||||
|
).fetchall()
|
||||||
|
return tuple(
|
||||||
|
StockMembershipEntry(
|
||||||
|
trade_date=row[0],
|
||||||
|
sector_type=SectorType(row[1]),
|
||||||
|
sector_code=row[2],
|
||||||
|
sector_name=row[3],
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
)
|
||||||
|
|
||||||
def save_stock_facts(self, records: Iterable[StockFactRecord]) -> WriteCounts:
|
def save_stock_facts(self, records: Iterable[StockFactRecord]) -> WriteCounts:
|
||||||
"""COPY normalized stock facts while preserving contributing source ids."""
|
"""COPY normalized stock facts while preserving contributing source ids."""
|
||||||
|
|
||||||
|
|||||||
@@ -8,17 +8,21 @@ from datetime import date, datetime
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Annotated, Literal
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from ....bootstrap.config import Settings, get_settings
|
from ....bootstrap.config import Settings, get_settings
|
||||||
from ..application.read import (
|
from ..application.read import (
|
||||||
|
STOCK_SECTOR_CONCEPT_LIMIT,
|
||||||
RadarDateIndex,
|
RadarDateIndex,
|
||||||
RadarMetricDefinition,
|
RadarMetricDefinition,
|
||||||
RadarQuery,
|
RadarQuery,
|
||||||
RadarView,
|
RadarView,
|
||||||
RankingPage,
|
RankingPage,
|
||||||
ReadSectorRadar,
|
ReadSectorRadar,
|
||||||
|
SectorRef,
|
||||||
|
StockSectorMembership,
|
||||||
|
StockSectorQuery,
|
||||||
)
|
)
|
||||||
from ..domain.models import (
|
from ..domain.models import (
|
||||||
MetricKind,
|
MetricKind,
|
||||||
@@ -122,6 +126,29 @@ class RadarRankingsResponse(BaseModel):
|
|||||||
rows: list[RadarRankingRowResponse] = Field(default_factory=_empty_ranking_rows)
|
rows: list[RadarRankingRowResponse] = Field(default_factory=_empty_ranking_rows)
|
||||||
|
|
||||||
|
|
||||||
|
class SectorRefResponse(BaseModel):
|
||||||
|
"""Public identity of one sector that claimed a stock."""
|
||||||
|
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class StockSectorMembershipResponse(BaseModel):
|
||||||
|
"""One stock's point-in-time industries and concepts from last-good build.
|
||||||
|
|
||||||
|
``trade_date`` is the actual membership snapshot date, never a newer one.
|
||||||
|
"""
|
||||||
|
|
||||||
|
status: Literal["success", "no_data"]
|
||||||
|
ts_code: str
|
||||||
|
requested_trade_date: date
|
||||||
|
trade_date: date | None
|
||||||
|
industries: list[SectorRefResponse] = Field(default_factory=list)
|
||||||
|
concepts: list[SectorRefResponse] = Field(default_factory=list)
|
||||||
|
concept_total: int = Field(ge=0)
|
||||||
|
concept_limit: int = Field(ge=1, le=100)
|
||||||
|
|
||||||
|
|
||||||
def get_sector_radar_reader(
|
def get_sector_radar_reader(
|
||||||
settings: Annotated[Settings, Depends(get_settings)],
|
settings: Annotated[Settings, Depends(get_settings)],
|
||||||
) -> ReadSectorRadar:
|
) -> ReadSectorRadar:
|
||||||
@@ -196,6 +223,28 @@ def get_sector_radar_rankings(
|
|||||||
raise _storage_error() from exc
|
raise _storage_error() from exc
|
||||||
|
|
||||||
|
|
||||||
|
@sector_radar_router.get(
|
||||||
|
"/stocks/{ts_code}/membership", response_model=StockSectorMembershipResponse
|
||||||
|
)
|
||||||
|
def get_stock_sector_membership(
|
||||||
|
reader: Annotated[ReadSectorRadar, Depends(get_sector_radar_reader)],
|
||||||
|
ts_code: Annotated[str, Path(min_length=1, max_length=12)],
|
||||||
|
trade_date: date,
|
||||||
|
concept_limit: Annotated[int, Query(ge=1, le=100)] = STOCK_SECTOR_CONCEPT_LIMIT,
|
||||||
|
) -> StockSectorMembershipResponse:
|
||||||
|
"""Return one stock's industries and concepts from the last-good snapshot."""
|
||||||
|
|
||||||
|
query = StockSectorQuery(
|
||||||
|
trade_date=trade_date,
|
||||||
|
ts_code=ts_code.strip(),
|
||||||
|
concept_limit=concept_limit,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return _stock_membership_response(reader.stock_membership(query))
|
||||||
|
except SectorRadarRepositoryError as exc:
|
||||||
|
raise _storage_error() from exc
|
||||||
|
|
||||||
|
|
||||||
def _dates_response(index: RadarDateIndex) -> RadarDatesResponse:
|
def _dates_response(index: RadarDateIndex) -> RadarDatesResponse:
|
||||||
return RadarDatesResponse(
|
return RadarDatesResponse(
|
||||||
status=index.status,
|
status=index.status,
|
||||||
@@ -231,6 +280,25 @@ def _rankings_response(page: RankingPage) -> RadarRankingsResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _stock_membership_response(
|
||||||
|
membership: StockSectorMembership,
|
||||||
|
) -> StockSectorMembershipResponse:
|
||||||
|
return StockSectorMembershipResponse(
|
||||||
|
status=membership.status,
|
||||||
|
ts_code=membership.query.ts_code,
|
||||||
|
requested_trade_date=membership.query.trade_date,
|
||||||
|
trade_date=membership.trade_date,
|
||||||
|
industries=[_sector_ref_response(ref) for ref in membership.industries],
|
||||||
|
concepts=[_sector_ref_response(ref) for ref in membership.concepts],
|
||||||
|
concept_total=membership.concept_total,
|
||||||
|
concept_limit=membership.query.concept_limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sector_ref_response(ref: SectorRef) -> SectorRefResponse:
|
||||||
|
return SectorRefResponse(code=ref.code, name=ref.name)
|
||||||
|
|
||||||
|
|
||||||
def _publication_response(publication: RadarPublication) -> RadarPublicationResponse:
|
def _publication_response(publication: RadarPublication) -> RadarPublicationResponse:
|
||||||
return RadarPublicationResponse(
|
return RadarPublicationResponse(
|
||||||
publication_id=publication.publication_id,
|
publication_id=publication.publication_id,
|
||||||
@@ -297,6 +365,7 @@ def _storage_error() -> HTTPException:
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"RadarDatesResponse",
|
"RadarDatesResponse",
|
||||||
"RadarRankingsResponse",
|
"RadarRankingsResponse",
|
||||||
|
"StockSectorMembershipResponse",
|
||||||
"get_sector_radar_reader",
|
"get_sector_radar_reader",
|
||||||
"sector_radar_router",
|
"sector_radar_router",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ from zhixing_server.modules.sector_radar.application.read import (
|
|||||||
RadarQuery,
|
RadarQuery,
|
||||||
RadarView,
|
RadarView,
|
||||||
RankingPage,
|
RankingPage,
|
||||||
|
SectorRef,
|
||||||
|
StockSectorMembership,
|
||||||
|
StockSectorQuery,
|
||||||
)
|
)
|
||||||
from zhixing_server.modules.sector_radar.domain.metrics import AmountNetStrategy
|
from zhixing_server.modules.sector_radar.domain.metrics import AmountNetStrategy
|
||||||
from zhixing_server.modules.sector_radar.domain.models import (
|
from zhixing_server.modules.sector_radar.domain.models import (
|
||||||
@@ -87,7 +90,9 @@ def _ranking() -> RankedMetric:
|
|||||||
class FakeReader:
|
class FakeReader:
|
||||||
def __init__(self, *, no_data: bool = False, fail: bool = False) -> None:
|
def __init__(self, *, no_data: bool = False, fail: bool = False) -> None:
|
||||||
self.fail = fail
|
self.fail = fail
|
||||||
|
self.no_data = no_data
|
||||||
self.last_query: RadarQuery | None = None
|
self.last_query: RadarQuery | None = None
|
||||||
|
self.last_membership_query: StockSectorQuery | None = None
|
||||||
success = _publication("publication-success", PublicationStatus.SUCCESS)
|
success = _publication("publication-success", PublicationStatus.SUCCESS)
|
||||||
current = _publication(
|
current = _publication(
|
||||||
"publication-partial",
|
"publication-partial",
|
||||||
@@ -125,6 +130,32 @@ class FakeReader:
|
|||||||
self.last_query = query
|
self.last_query = query
|
||||||
return replace(self.page, 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:
|
def _client(reader: FakeReader) -> TestClient:
|
||||||
application = create_app()
|
application = create_app()
|
||||||
@@ -228,3 +259,78 @@ def test_repository_error_maps_to_redacted_503() -> None:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert "private database detail" not in response.text
|
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 datetime import UTC, date, datetime, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from zhixing_server.modules.sector_radar.application.read import (
|
from zhixing_server.modules.sector_radar.application.read import (
|
||||||
RadarQuery,
|
RadarQuery,
|
||||||
RadarView,
|
RadarView,
|
||||||
ReadSectorRadar,
|
ReadSectorRadar,
|
||||||
|
SectorRef,
|
||||||
|
StockSectorQuery,
|
||||||
)
|
)
|
||||||
from zhixing_server.modules.sector_radar.domain.metrics import AmountNetStrategy
|
from zhixing_server.modules.sector_radar.domain.metrics import AmountNetStrategy
|
||||||
from zhixing_server.modules.sector_radar.domain.models import (
|
from zhixing_server.modules.sector_radar.domain.models import (
|
||||||
@@ -20,7 +24,10 @@ from zhixing_server.modules.sector_radar.domain.models import (
|
|||||||
RankSide,
|
RankSide,
|
||||||
SectorType,
|
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.domain.ranking import rank_metric_observations
|
||||||
from zhixing_server.modules.sector_radar.infrastructure.memory import (
|
from zhixing_server.modules.sector_radar.infrastructure.memory import (
|
||||||
InMemorySectorRadarRepository,
|
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 is not None
|
||||||
assert index.last_good.publication_id == "publication-success"
|
assert index.last_good.publication_id == "publication-success"
|
||||||
assert index.available_dates == (TARGET_DATE,)
|
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)
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ const requestJson = vi.hoisted(() => vi.fn())
|
|||||||
|
|
||||||
vi.mock("@/shared/api/request-json", () => ({ requestJson }))
|
vi.mock("@/shared/api/request-json", () => ({ requestJson }))
|
||||||
|
|
||||||
import { getSectorRadarDates, getSectorRadarRankings } from "./sector-radar.api"
|
import {
|
||||||
|
getSectorRadarDates,
|
||||||
|
getSectorRadarRankings,
|
||||||
|
getStockSectorMembership,
|
||||||
|
} from "./sector-radar.api"
|
||||||
|
|
||||||
const publication = {
|
const publication = {
|
||||||
publication_id: "publication-1",
|
publication_id: "publication-1",
|
||||||
@@ -180,3 +184,59 @@ describe("sector radar API adapters", () => {
|
|||||||
).rejects.toThrow("rankings.rows[0].rank_percentile must be greater than 0")
|
).rejects.toThrow("rankings.rows[0].rank_percentile must be greater than 0")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("stock sector membership adapter", () => {
|
||||||
|
const membershipPayload = {
|
||||||
|
status: "success",
|
||||||
|
ts_code: "000001.SZ",
|
||||||
|
requested_trade_date: "2026-08-28",
|
||||||
|
trade_date: "2026-08-28",
|
||||||
|
industries: [{ code: "BK0901.DC", name: "银行" }],
|
||||||
|
concepts: [
|
||||||
|
{ code: "BK0001.DC", name: "机器人" },
|
||||||
|
{ code: "BK0002.DC", name: "人工智能" },
|
||||||
|
],
|
||||||
|
concept_total: 2,
|
||||||
|
concept_limit: 20,
|
||||||
|
}
|
||||||
|
|
||||||
|
it("requests the snake-case endpoint and validates the payload", async () => {
|
||||||
|
requestJson.mockResolvedValue(membershipPayload)
|
||||||
|
|
||||||
|
const result = await getStockSectorMembership("000001.SZ", "2026-08-28")
|
||||||
|
|
||||||
|
expect(requestJson).toHaveBeenCalledWith(
|
||||||
|
"/api/v1/sector-radar/stocks/000001.SZ/membership?trade_date=2026-08-28",
|
||||||
|
{ signal: undefined },
|
||||||
|
)
|
||||||
|
expect(result).toEqual(membershipPayload)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("keeps the no_data status with a null snapshot date", async () => {
|
||||||
|
requestJson.mockResolvedValue({
|
||||||
|
...membershipPayload,
|
||||||
|
status: "no_data",
|
||||||
|
trade_date: null,
|
||||||
|
industries: [],
|
||||||
|
concepts: [],
|
||||||
|
concept_total: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await getStockSectorMembership("000001.SZ", "2026-08-28")
|
||||||
|
|
||||||
|
expect(result.status).toBe("no_data")
|
||||||
|
expect(result.trade_date).toBeNull()
|
||||||
|
expect(result.industries).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects a membership response with invalid sector references", async () => {
|
||||||
|
requestJson.mockResolvedValue({
|
||||||
|
...membershipPayload,
|
||||||
|
industries: [{ code: "BK0901.DC", name: " " }],
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
getStockSectorMembership("000001.SZ", "2026-08-28"),
|
||||||
|
).rejects.toThrow("membership.industries[0].name must be a non-empty string")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
type RadarRankingRow,
|
type RadarRankingRow,
|
||||||
type RadarRankingsQuery,
|
type RadarRankingsQuery,
|
||||||
type RadarRankingsResponse,
|
type RadarRankingsResponse,
|
||||||
|
type StockSectorMembershipResponse,
|
||||||
|
type StockSectorRef,
|
||||||
} from "./sector-radar.types"
|
} from "./sector-radar.types"
|
||||||
|
|
||||||
type JsonRecord = Record<string, unknown>
|
type JsonRecord = Record<string, unknown>
|
||||||
@@ -145,6 +147,69 @@ export function parseRadarRankingsResponse(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch one stock's industries and concepts from the last-good build snapshot.
|
||||||
|
*
|
||||||
|
* @param tsCode - Stock code such as `000001.SZ`.
|
||||||
|
* @param tradeDate - Requested trade date; the backend answers with the
|
||||||
|
* newest snapshot at or before it and reports that exact date.
|
||||||
|
* @param signal - React Query cancellation signal forwarded to fetch.
|
||||||
|
* @returns A validated membership response with grouped sector references.
|
||||||
|
* @throws Error when the server response violates the stable HTTP contract.
|
||||||
|
*/
|
||||||
|
export async function getStockSectorMembership(
|
||||||
|
tsCode: string,
|
||||||
|
tradeDate: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) {
|
||||||
|
const params = new URLSearchParams({ trade_date: tradeDate })
|
||||||
|
const payload = await requestJson<unknown>(
|
||||||
|
`/api/v1/sector-radar/stocks/${encodeURIComponent(tsCode)}/membership?${params.toString()}`,
|
||||||
|
{ signal },
|
||||||
|
)
|
||||||
|
return parseStockSectorMembershipResponse(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate one stock-membership response at the feature boundary. */
|
||||||
|
export function parseStockSectorMembershipResponse(
|
||||||
|
value: unknown,
|
||||||
|
): StockSectorMembershipResponse {
|
||||||
|
const record = readRecord(value, "membership")
|
||||||
|
return {
|
||||||
|
status: readEnum(record.status, ["success", "no_data"], "membership.status"),
|
||||||
|
ts_code: readNonEmptyString(record.ts_code, "membership.ts_code"),
|
||||||
|
requested_trade_date: readDate(
|
||||||
|
record.requested_trade_date,
|
||||||
|
"membership.requested_trade_date",
|
||||||
|
),
|
||||||
|
trade_date: readNullableDate(record.trade_date, "membership.trade_date"),
|
||||||
|
industries: readSectorRefs(record.industries, "membership.industries"),
|
||||||
|
concepts: readSectorRefs(record.concepts, "membership.concepts"),
|
||||||
|
concept_total: readIntegerInRange(
|
||||||
|
record.concept_total,
|
||||||
|
0,
|
||||||
|
Number.MAX_SAFE_INTEGER,
|
||||||
|
"membership.concept_total",
|
||||||
|
),
|
||||||
|
concept_limit: readIntegerInRange(
|
||||||
|
record.concept_limit,
|
||||||
|
1,
|
||||||
|
100,
|
||||||
|
"membership.concept_limit",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSectorRefs(value: unknown, path: string): StockSectorRef[] {
|
||||||
|
return readArray(value, path).map((item, index) => {
|
||||||
|
const ref = readRecord(item, `${path}[${index}]`)
|
||||||
|
return {
|
||||||
|
code: readNonEmptyString(ref.code, `${path}[${index}].code`),
|
||||||
|
name: readNonEmptyString(ref.name, `${path}[${index}].name`),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function readNullablePublication(
|
function readNullablePublication(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
path: string,
|
path: string,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
type InfiniteData,
|
type InfiniteData,
|
||||||
} from "@tanstack/react-query"
|
} from "@tanstack/react-query"
|
||||||
|
|
||||||
import { getSectorRadarDates, getSectorRadarRankings } from "./sector-radar.api"
|
import { getSectorRadarDates, getSectorRadarRankings, getStockSectorMembership } from "./sector-radar.api"
|
||||||
import type {
|
import type {
|
||||||
RadarRankingsQuery,
|
RadarRankingsQuery,
|
||||||
RadarRankingsResponse,
|
RadarRankingsResponse,
|
||||||
@@ -12,6 +12,34 @@ import type {
|
|||||||
|
|
||||||
export const sectorRadarDatesQueryKey = ["sectorRadar", "dates"] as const
|
export const sectorRadarDatesQueryKey = ["sectorRadar", "dates"] as const
|
||||||
|
|
||||||
|
export const stockSectorMembershipQueryKey = (
|
||||||
|
tsCode?: string,
|
||||||
|
tradeDate?: string,
|
||||||
|
) =>
|
||||||
|
[
|
||||||
|
"sectorRadar",
|
||||||
|
"stockMembership",
|
||||||
|
tsCode ?? "none",
|
||||||
|
tradeDate ?? "none",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read one stock's point-in-time industries and concepts for a detail view.
|
||||||
|
*
|
||||||
|
* Disabled until both the stock code and target trade date are known; the
|
||||||
|
* query stays cached per code-date pair so switching selections refetches
|
||||||
|
* only unseen pairs.
|
||||||
|
*/
|
||||||
|
export function useStockSectorMembership(tsCode?: string, tradeDate?: string) {
|
||||||
|
return useQuery({
|
||||||
|
enabled: Boolean(tsCode && tradeDate),
|
||||||
|
queryFn: ({ signal }) =>
|
||||||
|
getStockSectorMembership(tsCode ?? "", tradeDate ?? "", signal),
|
||||||
|
queryKey: stockSectorMembershipQueryKey(tsCode, tradeDate),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export const sectorRadarRankingsQueryKey = (query: RadarRankingsQuery) =>
|
export const sectorRadarRankingsQueryKey = (query: RadarRankingsQuery) =>
|
||||||
[
|
[
|
||||||
"sectorRadar",
|
"sectorRadar",
|
||||||
|
|||||||
@@ -111,3 +111,19 @@ export interface RadarRankingsQuery {
|
|||||||
export interface SectorRadarRouteSearch extends RadarRankingsQuery {
|
export interface SectorRadarRouteSearch extends RadarRankingsQuery {
|
||||||
search: string
|
search: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StockSectorRef {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StockSectorMembershipResponse {
|
||||||
|
status: "success" | "no_data"
|
||||||
|
ts_code: string
|
||||||
|
requested_trade_date: string
|
||||||
|
trade_date: string | null
|
||||||
|
industries: StockSectorRef[]
|
||||||
|
concepts: StockSectorRef[]
|
||||||
|
concept_total: number
|
||||||
|
concept_limit: number
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { render, screen } from "@testing-library/react"
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
|
|
||||||
|
import type { SelectionStockResult } from "../api/selection.types"
|
||||||
|
import { SignalDetailPanel } from "./signal-detail-panel"
|
||||||
|
|
||||||
|
const useSelectionChart = vi.fn()
|
||||||
|
const useStockSectorMembership = vi.fn()
|
||||||
|
|
||||||
|
vi.mock("../api/selection.query", () => ({
|
||||||
|
useSelectionChart: (...args: unknown[]) => useSelectionChart(...args),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("@/features/sector-radar/api/sector-radar.query", () => ({
|
||||||
|
useStockSectorMembership: (...args: unknown[]) =>
|
||||||
|
useStockSectorMembership(...args),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("./selection-chart", () => ({
|
||||||
|
SelectionChart: () => <div>selection-chart</div>,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("./pattern-case-image", () => ({
|
||||||
|
PatternCaseImage: () => <div>pattern-case-image</div>,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const stock: SelectionStockResult = {
|
||||||
|
close: 10.5,
|
||||||
|
name: "平安银行",
|
||||||
|
score: null,
|
||||||
|
signals: [],
|
||||||
|
strategy: "zhixing_b1",
|
||||||
|
target_trade_date: "2026-08-28",
|
||||||
|
ts_code: "000001.SZ",
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SignalDetailPanel", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useSelectionChart.mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
isError: false,
|
||||||
|
isPending: true,
|
||||||
|
})
|
||||||
|
useStockSectorMembership.mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
isError: false,
|
||||||
|
isPending: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("shows the stock industry and concept boards from the membership query", () => {
|
||||||
|
useStockSectorMembership.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
status: "success",
|
||||||
|
ts_code: "000001.SZ",
|
||||||
|
requested_trade_date: "2026-08-28",
|
||||||
|
trade_date: "2026-08-28",
|
||||||
|
industries: [{ code: "BK0901.DC", name: "银行" }],
|
||||||
|
concepts: [
|
||||||
|
{ code: "BK0001.DC", name: "机器人" },
|
||||||
|
{ code: "BK0002.DC", name: "人工智能" },
|
||||||
|
],
|
||||||
|
concept_total: 2,
|
||||||
|
concept_limit: 20,
|
||||||
|
},
|
||||||
|
isError: false,
|
||||||
|
isPending: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<SignalDetailPanel stock={stock} />)
|
||||||
|
|
||||||
|
expect(screen.getByText("行业:银行")).toBeInTheDocument()
|
||||||
|
expect(screen.getByText("板块")).toBeInTheDocument()
|
||||||
|
expect(screen.getByText("机器人")).toBeInTheDocument()
|
||||||
|
expect(screen.getByText("人工智能")).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText(/等 \d+ 个/)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("caps visible concepts and reports the total", () => {
|
||||||
|
const concepts = Array.from({ length: 10 }, (_, index) => ({
|
||||||
|
code: `BK${String(index + 1).padStart(4, "0")}.DC`,
|
||||||
|
name: `板块${index + 1}`,
|
||||||
|
}))
|
||||||
|
useStockSectorMembership.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
status: "success",
|
||||||
|
ts_code: "000001.SZ",
|
||||||
|
requested_trade_date: "2026-08-28",
|
||||||
|
trade_date: "2026-08-28",
|
||||||
|
industries: [],
|
||||||
|
concepts,
|
||||||
|
concept_total: 10,
|
||||||
|
concept_limit: 20,
|
||||||
|
},
|
||||||
|
isError: false,
|
||||||
|
isPending: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<SignalDetailPanel stock={stock} />)
|
||||||
|
|
||||||
|
expect(screen.getByText("板块1")).toBeInTheDocument()
|
||||||
|
expect(screen.getByText("板块8")).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText("板块9")).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByText("等 10 个")).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("hides the sector rows when the membership query returns no data", () => {
|
||||||
|
useStockSectorMembership.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
status: "no_data",
|
||||||
|
ts_code: "000001.SZ",
|
||||||
|
requested_trade_date: "2026-08-28",
|
||||||
|
trade_date: null,
|
||||||
|
industries: [],
|
||||||
|
concepts: [],
|
||||||
|
concept_total: 0,
|
||||||
|
concept_limit: 20,
|
||||||
|
},
|
||||||
|
isError: false,
|
||||||
|
isPending: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<SignalDetailPanel stock={stock} />)
|
||||||
|
|
||||||
|
expect(screen.queryByText(/行业:/)).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText("板块")).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { lazy, Suspense } from "react"
|
import { lazy, Suspense } from "react"
|
||||||
import { Search } from "lucide-react"
|
import { Search } from "lucide-react"
|
||||||
|
import { useStockSectorMembership } from "@/features/sector-radar/api/sector-radar.query"
|
||||||
import { useSelectionChart } from "../api/selection.query"
|
import { useSelectionChart } from "../api/selection.query"
|
||||||
|
|
||||||
import { Card } from "@/shared/ui/card"
|
import { Card } from "@/shared/ui/card"
|
||||||
@@ -18,6 +19,8 @@ const SelectionChart = lazy(() =>
|
|||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const displayedConceptCount = 8
|
||||||
|
|
||||||
interface SignalDetailPanelProps {
|
interface SignalDetailPanelProps {
|
||||||
stock: SelectionStockResult | undefined
|
stock: SelectionStockResult | undefined
|
||||||
}
|
}
|
||||||
@@ -28,6 +31,10 @@ export function SignalDetailPanel({ stock }: SignalDetailPanelProps) {
|
|||||||
stock?.target_trade_date,
|
stock?.target_trade_date,
|
||||||
stock?.strategy,
|
stock?.strategy,
|
||||||
)
|
)
|
||||||
|
const membershipQuery = useStockSectorMembership(
|
||||||
|
stock?.ts_code,
|
||||||
|
stock?.target_trade_date,
|
||||||
|
)
|
||||||
|
|
||||||
if (!stock) {
|
if (!stock) {
|
||||||
return (
|
return (
|
||||||
@@ -63,6 +70,19 @@ export function SignalDetailPanel({ stock }: SignalDetailPanelProps) {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const membership =
|
||||||
|
membershipQuery.data?.status === "success" ? membershipQuery.data : undefined
|
||||||
|
const industries = membership?.industries ?? []
|
||||||
|
const concepts = membership?.concepts ?? []
|
||||||
|
const extraConceptCount = Math.max(
|
||||||
|
0,
|
||||||
|
concepts.length - displayedConceptCount,
|
||||||
|
)
|
||||||
|
const sectorSnapshotTitle =
|
||||||
|
membership?.trade_date != null
|
||||||
|
? `板块成分截至 ${membership.trade_date}`
|
||||||
|
: undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
aria-label="当前股票详情"
|
aria-label="当前股票详情"
|
||||||
@@ -117,6 +137,33 @@ export function SignalDetailPanel({ stock }: SignalDetailPanelProps) {
|
|||||||
¥ {stock.close.toFixed(2)}
|
¥ {stock.close.toFixed(2)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{industries.length > 0 && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
行业:{industries.map((industry) => industry.name).join("、")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{concepts.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="mt-2 flex flex-wrap items-center gap-1"
|
||||||
|
title={sectorSnapshotTitle}
|
||||||
|
>
|
||||||
|
<span className="text-xs text-muted-foreground">板块</span>
|
||||||
|
{concepts.slice(0, displayedConceptCount).map((concept) => (
|
||||||
|
<span
|
||||||
|
className="rounded bg-muted px-1.5 py-0.5 text-xs"
|
||||||
|
key={concept.code}
|
||||||
|
>
|
||||||
|
{concept.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{extraConceptCount > 0 && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
等 {concepts.length} 个
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<dl className="mt-3 divide-y divide-border/60 border-y border-border/60 text-xs">
|
<dl className="mt-3 divide-y divide-border/60 border-y border-border/60 text-xs">
|
||||||
{metricRows.map((row) => (
|
{metricRows.map((row) => (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { SelectionResultsPage } from "./selection-results-page"
|
|||||||
const routerNavigate = vi.hoisted(() => vi.fn())
|
const routerNavigate = vi.hoisted(() => vi.fn())
|
||||||
const useSelectionResults = vi.fn()
|
const useSelectionResults = vi.fn()
|
||||||
const useSelectionChart = vi.fn()
|
const useSelectionChart = vi.fn()
|
||||||
|
const useStockSectorMembership = vi.fn()
|
||||||
const useSelectionRun = vi.fn()
|
const useSelectionRun = vi.fn()
|
||||||
const useTriggerSelectionRun = vi.fn()
|
const useTriggerSelectionRun = vi.fn()
|
||||||
const mutate = vi.fn()
|
const mutate = vi.fn()
|
||||||
@@ -36,6 +37,11 @@ vi.mock("@/features/selection/api/selection.query", async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
vi.mock("@/features/sector-radar/api/sector-radar.query", () => ({
|
||||||
|
useStockSectorMembership: (...args: unknown[]) =>
|
||||||
|
useStockSectorMembership(...args),
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock("@tanstack/react-router", () => ({
|
vi.mock("@tanstack/react-router", () => ({
|
||||||
useNavigate: () => routerNavigate,
|
useNavigate: () => routerNavigate,
|
||||||
useSearch: () => ({
|
useSearch: () => ({
|
||||||
@@ -157,6 +163,11 @@ describe("SelectionResultsPage", () => {
|
|||||||
isError: false,
|
isError: false,
|
||||||
isPending: false,
|
isPending: false,
|
||||||
})
|
})
|
||||||
|
useStockSectorMembership.mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
isError: false,
|
||||||
|
isPending: false,
|
||||||
|
})
|
||||||
useSelectionResults.mockReturnValue(selectionQueryResult(selectedResult))
|
useSelectionResults.mockReturnValue(selectionQueryResult(selectedResult))
|
||||||
useSelectionRun.mockReturnValue(emptySelectionQuery())
|
useSelectionRun.mockReturnValue(emptySelectionQuery())
|
||||||
useTriggerSelectionRun.mockReturnValue({
|
useTriggerSelectionRun.mockReturnValue({
|
||||||
|
|||||||
Reference in New Issue
Block a user