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 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",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user