feat(selection): show stock industry and concept boards in detail panel

Add point-in-time stock membership lookup to the sector radar module
(GET /sector-radar/stocks/{ts_code}/membership) reading the existing
dc_index/dc_member snapshots, and surface industries plus concept chips
in the selection signal detail panel with graceful no-data hiding.
This commit is contained in:
yuxuanhui
2026-09-05 16:23:50 +08:00
parent 50cac3575a
commit 41a9b4eb9a
14 changed files with 825 additions and 5 deletions
@@ -2,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",
]