Merge branch 'develop' into main
Deploy Production / deploy (push) Successful in 29s

This commit is contained in:
yuxuanhui
2026-09-05 16:27:43 +08:00
14 changed files with 825 additions and 5 deletions
@@ -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",
]
@@ -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: ...
@@ -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."""
@@ -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."""
@@ -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",
]
@@ -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)
@@ -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")
})
})
@@ -14,6 +14,8 @@ import {
type RadarRankingRow,
type RadarRankingsQuery,
type RadarRankingsResponse,
type StockSectorMembershipResponse,
type StockSectorRef,
} from "./sector-radar.types"
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(
value: unknown,
path: string,
@@ -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",
@@ -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
}
@@ -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 { 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 (
<aside
aria-label="当前股票详情"
@@ -117,6 +137,33 @@ export function SignalDetailPanel({ stock }: SignalDetailPanelProps) {
¥ {stock.close.toFixed(2)}
</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">
{metricRows.map((row) => (
<div
@@ -17,6 +17,7 @@ import { SelectionResultsPage } from "./selection-results-page"
const routerNavigate = vi.hoisted(() => vi.fn())
const useSelectionResults = vi.fn()
const useSelectionChart = vi.fn()
const useStockSectorMembership = vi.fn()
const useSelectionRun = vi.fn()
const useTriggerSelectionRun = 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", () => ({
useNavigate: () => routerNavigate,
useSearch: () => ({
@@ -157,6 +163,11 @@ describe("SelectionResultsPage", () => {
isError: false,
isPending: false,
})
useStockSectorMembership.mockReturnValue({
data: undefined,
isError: false,
isPending: false,
})
useSelectionResults.mockReturnValue(selectionQueryResult(selectedResult))
useSelectionRun.mockReturnValue(emptySelectionQuery())
useTriggerSelectionRun.mockReturnValue({