diff --git a/zhixing-server/src/zhixing_server/modules/sector_radar/application/read.py b/zhixing-server/src/zhixing_server/modules/sector_radar/application/read.py index 0313502..4c69780 100644 --- a/zhixing-server/src/zhixing_server/modules/sector_radar/application/read.py +++ b/zhixing-server/src/zhixing_server/modules/sector_radar/application/read.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Iterator from dataclasses import dataclass from datetime import date from enum import StrEnum @@ -20,11 +21,13 @@ from ..domain.models import ( RankSide, SectorType, ) -from ..domain.persistence import SectorRadarRepository +from ..domain.persistence import SectorRadarRepository, StockMembershipEntry from ..domain.ranking import select_percentile_side, select_rank_change_side ReadStatus = Literal["success", "no_data"] +STOCK_SECTOR_CONCEPT_LIMIT = 20 + class RadarView(StrEnum): """Supported ranking projections at the HTTP boundary.""" @@ -101,6 +104,50 @@ class RankingPage: 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 = { MetricKind.AMOUNT: RadarMetricDefinition( metric_kind=MetricKind.AMOUNT, @@ -212,6 +259,48 @@ class ReadSectorRadar: 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__ = [ "RadarDateIndex", @@ -220,4 +309,8 @@ __all__ = [ "RadarView", "RankingPage", "ReadSectorRadar", + "SectorRef", + "StockSectorMembership", + "StockSectorQuery", + "STOCK_SECTOR_CONCEPT_LIMIT", ] diff --git a/zhixing-server/src/zhixing_server/modules/sector_radar/domain/persistence.py b/zhixing-server/src/zhixing_server/modules/sector_radar/domain/persistence.py index 1c83b82..df20cb6 100644 --- a/zhixing-server/src/zhixing_server/modules/sector_radar/domain/persistence.py +++ b/zhixing-server/src/zhixing_server/modules/sector_radar/domain/persistence.py @@ -65,6 +65,22 @@ class MembershipRecord: 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) class StockFactRecord: """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 load_stock_memberships( + self, trade_date: date, stock_code: str + ) -> Sequence[StockMembershipEntry]: ... + def save_stock_facts(self, records: Iterable[StockFactRecord]) -> WriteCounts: ... def save_daily_aggregates(self, records: Iterable[DailyAggregateRecord]) -> WriteCounts: ... diff --git a/zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/memory.py b/zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/memory.py index 903f21e..1d5ee0d 100644 --- a/zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/memory.py +++ b/zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/memory.py @@ -15,6 +15,7 @@ from ..domain.persistence import ( PublicationSourceRecord, RankingRecord, StockFactRecord, + StockMembershipEntry, WriteCounts, ) 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: """Insert normalized fact revisions idempotently.""" diff --git a/zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/postgres.py b/zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/postgres.py index 1b5be9e..6508fd1 100644 --- a/zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/postgres.py +++ b/zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/postgres.py @@ -31,6 +31,7 @@ from ..domain.persistence import ( PublicationSourceRecord, RankingRecord, StockFactRecord, + StockMembershipEntry, WriteCounts, ) from ..domain.source import SourceSnapshot @@ -283,6 +284,32 @@ class PostgresSectorRadarRepository: 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: """COPY normalized stock facts while preserving contributing source ids.""" diff --git a/zhixing-server/src/zhixing_server/modules/sector_radar/presentation/http.py b/zhixing-server/src/zhixing_server/modules/sector_radar/presentation/http.py index 10c9892..bd4ac85 100644 --- a/zhixing-server/src/zhixing_server/modules/sector_radar/presentation/http.py +++ b/zhixing-server/src/zhixing_server/modules/sector_radar/presentation/http.py @@ -8,17 +8,21 @@ from datetime import date, datetime from decimal import Decimal 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 ....bootstrap.config import Settings, get_settings from ..application.read import ( + STOCK_SECTOR_CONCEPT_LIMIT, RadarDateIndex, RadarMetricDefinition, RadarQuery, RadarView, RankingPage, ReadSectorRadar, + SectorRef, + StockSectorMembership, + StockSectorQuery, ) from ..domain.models import ( MetricKind, @@ -122,6 +126,29 @@ class RadarRankingsResponse(BaseModel): 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( settings: Annotated[Settings, Depends(get_settings)], ) -> ReadSectorRadar: @@ -196,6 +223,28 @@ def get_sector_radar_rankings( 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: return RadarDatesResponse( 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: return RadarPublicationResponse( publication_id=publication.publication_id, @@ -297,6 +365,7 @@ def _storage_error() -> HTTPException: __all__ = [ "RadarDatesResponse", "RadarRankingsResponse", + "StockSectorMembershipResponse", "get_sector_radar_reader", "sector_radar_router", ] diff --git a/zhixing-server/tests/test_sector_radar_http.py b/zhixing-server/tests/test_sector_radar_http.py index 2aa7ee9..491078c 100644 --- a/zhixing-server/tests/test_sector_radar_http.py +++ b/zhixing-server/tests/test_sector_radar_http.py @@ -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 diff --git a/zhixing-server/tests/unit/sector_radar/test_read.py b/zhixing-server/tests/unit/sector_radar/test_read.py index c071e56..c789afc 100644 --- a/zhixing-server/tests/unit/sector_radar/test_read.py +++ b/zhixing-server/tests/unit/sector_radar/test_read.py @@ -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) diff --git a/zhixing-web/src/features/sector-radar/api/sector-radar.api.test.ts b/zhixing-web/src/features/sector-radar/api/sector-radar.api.test.ts index a77a8be..f20223b 100644 --- a/zhixing-web/src/features/sector-radar/api/sector-radar.api.test.ts +++ b/zhixing-web/src/features/sector-radar/api/sector-radar.api.test.ts @@ -4,7 +4,11 @@ const requestJson = vi.hoisted(() => vi.fn()) vi.mock("@/shared/api/request-json", () => ({ requestJson })) -import { getSectorRadarDates, getSectorRadarRankings } from "./sector-radar.api" +import { + getSectorRadarDates, + getSectorRadarRankings, + getStockSectorMembership, +} from "./sector-radar.api" const publication = { 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") }) }) + +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") + }) +}) diff --git a/zhixing-web/src/features/sector-radar/api/sector-radar.api.ts b/zhixing-web/src/features/sector-radar/api/sector-radar.api.ts index dd5e1ba..4dd0336 100644 --- a/zhixing-web/src/features/sector-radar/api/sector-radar.api.ts +++ b/zhixing-web/src/features/sector-radar/api/sector-radar.api.ts @@ -14,6 +14,8 @@ import { type RadarRankingRow, type RadarRankingsQuery, type RadarRankingsResponse, + type StockSectorMembershipResponse, + type StockSectorRef, } from "./sector-radar.types" type JsonRecord = Record @@ -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( + `/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( value: unknown, path: string, diff --git a/zhixing-web/src/features/sector-radar/api/sector-radar.query.ts b/zhixing-web/src/features/sector-radar/api/sector-radar.query.ts index 50f8c6a..fc49abf 100644 --- a/zhixing-web/src/features/sector-radar/api/sector-radar.query.ts +++ b/zhixing-web/src/features/sector-radar/api/sector-radar.query.ts @@ -4,7 +4,7 @@ import { type InfiniteData, } from "@tanstack/react-query" -import { getSectorRadarDates, getSectorRadarRankings } from "./sector-radar.api" +import { getSectorRadarDates, getSectorRadarRankings, getStockSectorMembership } from "./sector-radar.api" import type { RadarRankingsQuery, RadarRankingsResponse, @@ -12,6 +12,34 @@ import type { 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) => [ "sectorRadar", diff --git a/zhixing-web/src/features/sector-radar/api/sector-radar.types.ts b/zhixing-web/src/features/sector-radar/api/sector-radar.types.ts index 1300fca..51253ac 100644 --- a/zhixing-web/src/features/sector-radar/api/sector-radar.types.ts +++ b/zhixing-web/src/features/sector-radar/api/sector-radar.types.ts @@ -111,3 +111,19 @@ export interface RadarRankingsQuery { export interface SectorRadarRouteSearch extends RadarRankingsQuery { 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 +} diff --git a/zhixing-web/src/features/selection/components/signal-detail-panel.test.tsx b/zhixing-web/src/features/selection/components/signal-detail-panel.test.tsx new file mode 100644 index 0000000..f7aeef5 --- /dev/null +++ b/zhixing-web/src/features/selection/components/signal-detail-panel.test.tsx @@ -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: () =>
selection-chart
, +})) + +vi.mock("./pattern-case-image", () => ({ + PatternCaseImage: () =>
pattern-case-image
, +})) + +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() + + 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() + + 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() + + expect(screen.queryByText(/行业:/)).not.toBeInTheDocument() + expect(screen.queryByText("板块")).not.toBeInTheDocument() + }) +}) diff --git a/zhixing-web/src/features/selection/components/signal-detail-panel.tsx b/zhixing-web/src/features/selection/components/signal-detail-panel.tsx index e772dc8..ea4b1a0 100644 --- a/zhixing-web/src/features/selection/components/signal-detail-panel.tsx +++ b/zhixing-web/src/features/selection/components/signal-detail-panel.tsx @@ -1,5 +1,6 @@ import { lazy, Suspense } from "react" import { Search } from "lucide-react" +import { useStockSectorMembership } from "@/features/sector-radar/api/sector-radar.query" import { useSelectionChart } from "../api/selection.query" import { Card } from "@/shared/ui/card" @@ -18,6 +19,8 @@ const SelectionChart = lazy(() => })), ) +const displayedConceptCount = 8 + interface SignalDetailPanelProps { stock: SelectionStockResult | undefined } @@ -28,6 +31,10 @@ export function SignalDetailPanel({ stock }: SignalDetailPanelProps) { stock?.target_trade_date, stock?.strategy, ) + const membershipQuery = useStockSectorMembership( + stock?.ts_code, + stock?.target_trade_date, + ) if (!stock) { 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 (