feat(sector-radar): 提供版本化排名查询接口

This commit is contained in:
yuxuanhui
2026-08-29 19:44:46 +08:00
parent d9bae722d8
commit efc4c3d530
10 changed files with 1115 additions and 12 deletions
@@ -39,9 +39,11 @@
## 4. HTTP 读取链 ## 4. HTTP 读取链
- [ ] 实现 `ReadSectorRadar` 查询模块以及 `/dates`、`/rankings` Pydantic 契约。 - [x] 实现 `ReadSectorRadar` 查询模块以及 `/dates`、`/rankings` Pydantic 契约。
- [ ] 在路由目录挂载 `/api/v1/sector-radar`;实现筛选、分页、搜索、rank-change 参数和 `no_data`/503 行为。 - [x] 在路由目录挂载 `/api/v1/sector-radar`;实现筛选、分页、搜索、rank-change 参数和 `no_data`/503 行为。
- [ ] 使用真实 `create_app()` 与 fake application dependency 写黑盒 HTTP 契约测试。 - [x] 使用真实 `create_app()` 与 fake application dependency 写黑盒 HTTP 契约测试。
阶段结果(2026-08-29):读取端严格区分最新尝试、指定日期成功修订和全局 last-good;概念/行业分池支持 amount、ratio、swing、rank_change、普通百分位强弱榜、排名变化强弱榜、搜索与分页。响应携带 publication/source/universe/metric 版本、单位、质量与“知行独立实现”声明;无成功发布稳定返回 200 `no_data`,参数错误返回 422,存储错误返回脱敏 503。完整后端门禁为 134 passed、3 skipped;跳过项仍为需要 `ZHIXING_TEST_DATABASE_URL` 的真实 PostgreSQL 集成测试。
## 5. 前端 MVP ## 5. 前端 MVP
@@ -5,6 +5,7 @@ from fastapi import APIRouter
from zhixing_server.interfaces.http.system import operational_router, system_router from zhixing_server.interfaces.http.system import operational_router, system_router
from zhixing_server.modules.market_data.presentation.home import home_router from zhixing_server.modules.market_data.presentation.home import home_router
from zhixing_server.modules.market_data.presentation.integrity import integrity_router from zhixing_server.modules.market_data.presentation.integrity import integrity_router
from zhixing_server.modules.sector_radar.presentation.http import sector_radar_router
from zhixing_server.modules.selection.presentation.http import selection_router from zhixing_server.modules.selection.presentation.http import selection_router
api_v1_router = APIRouter(prefix="/api/v1") api_v1_router = APIRouter(prefix="/api/v1")
@@ -16,5 +17,10 @@ api_v1_router.include_router(
tags=["market-data"], tags=["market-data"],
) )
api_v1_router.include_router(selection_router, prefix="/selection", tags=["selection"]) api_v1_router.include_router(selection_router, prefix="/selection", tags=["selection"])
api_v1_router.include_router(
sector_radar_router,
prefix="/sector-radar",
tags=["sector-radar"],
)
__all__ = ["api_v1_router", "operational_router"] __all__ = ["api_v1_router", "operational_router"]
@@ -0,0 +1,223 @@
"""Stable read model for persisted sector radar publications."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from enum import StrEnum
from typing import Literal
from ..domain.metrics import (
AmountNetStrategy,
RatioTurnoverStrategy,
SwingEqualThreeToTenStrategy,
)
from ..domain.models import (
MetricKind,
MetricUnit,
RadarPublication,
RankedMetric,
RankSide,
SectorType,
)
from ..domain.persistence import SectorRadarRepository
from ..domain.ranking import select_percentile_side, select_rank_change_side
ReadStatus = Literal["success", "no_data"]
class RadarView(StrEnum):
"""Supported ranking projections at the HTTP boundary."""
AMOUNT = "amount"
RATIO = "ratio"
SWING = "swing"
RANK_CHANGE = "rank_change"
@dataclass(frozen=True, slots=True)
class RadarMetricDefinition:
"""Public definition of one explicitly independent metric implementation."""
metric_kind: MetricKind
metric_version: str
label: str
unit: MetricUnit
implementation_kind: Literal["independent"] = "independent"
disclaimer: str = "知行独立实现,非 OneChartLab 原站公式"
@dataclass(frozen=True, slots=True)
class RadarQuery:
"""Validated application query for one ranking page."""
trade_date: date | None = None
sector_type: SectorType = SectorType.CONCEPT
view: RadarView = RadarView.AMOUNT
rank_change_metric: MetricKind = MetricKind.AMOUNT
rank_change_days: int = 1
side: RankSide = RankSide.ALL
search: str | None = None
page: int = 1
page_size: int = 20
def __post_init__(self) -> None:
"""Reject invalid pagination and rank-history offsets outside HTTP usage."""
if not 1 <= self.rank_change_days <= 5:
raise ValueError("rank_change_days must be between 1 and 5")
if self.page < 1:
raise ValueError("page must be positive")
if not 1 <= self.page_size <= 100:
raise ValueError("page_size must be between 1 and 100")
if self.search is not None and len(self.search) > 100:
raise ValueError("search must not exceed 100 characters")
@dataclass(frozen=True, slots=True)
class RadarDateIndex:
"""Available successful dates plus the newest attempt and strict last-good."""
available_dates: tuple[date, ...]
current_attempt: RadarPublication | None
last_good: RadarPublication | None
@property
def status(self) -> ReadStatus:
"""Return no_data until at least one successful publication exists."""
return "success" if self.last_good is not None else "no_data"
@dataclass(frozen=True, slots=True)
class RankingPage:
"""One filtered page without losing publication or metric provenance."""
status: ReadStatus
query: RadarQuery
publication: RadarPublication | None
definition: RadarMetricDefinition
rows: tuple[RankedMetric, ...]
total: int
_METRIC_DEFINITIONS = {
MetricKind.AMOUNT: RadarMetricDefinition(
metric_kind=MetricKind.AMOUNT,
metric_version=AmountNetStrategy.metric_version,
label="主力净流入(知行独立实现)",
unit=MetricUnit.CNY_100M,
),
MetricKind.RATIO: RadarMetricDefinition(
metric_kind=MetricKind.RATIO,
metric_version=RatioTurnoverStrategy.metric_version,
label="主力净流入/成交额(知行独立实现)",
unit=MetricUnit.RATIO,
),
MetricKind.SWING: RadarMetricDefinition(
metric_kind=MetricKind.SWING,
metric_version=SwingEqualThreeToTenStrategy.metric_version,
label="3—10 日等权资金率(知行独立实现)",
unit=MetricUnit.RATIO,
),
}
class ReadSectorRadar:
"""Hide last-good selection, ranking filters, search, and pagination."""
def __init__(self, repository: SectorRadarRepository) -> None:
self.repository = repository
def list_dates(self) -> RadarDateIndex:
"""Return successful dates without promoting partial or failed attempts."""
return RadarDateIndex(
available_dates=tuple(self.repository.list_successful_dates()),
current_attempt=self.repository.get_latest_publication(),
last_good=self.repository.get_last_good_publication(),
)
def query(self, query: RadarQuery) -> RankingPage:
"""Return one deterministic page for ordinary or rank-change views."""
metric_kind = (
query.rank_change_metric
if query.view is RadarView.RANK_CHANGE
else MetricKind(query.view.value)
)
definition = _METRIC_DEFINITIONS[metric_kind]
publication = (
self.repository.get_successful_publication(query.trade_date)
if query.trade_date is not None
else self.repository.get_last_good_publication()
)
if publication is None:
return RankingPage("no_data", query, None, definition, (), 0)
metric_rows = tuple(
row
for row in self.repository.load_rankings(publication.publication_id)
if row.observation.sector_type is query.sector_type
and row.observation.metric_kind is metric_kind
and row.observation.metric_version == definition.metric_version
)
if query.view is RadarView.RANK_CHANGE:
if query.side is RankSide.ALL:
selected = tuple(
sorted(
metric_rows,
key=lambda row: (
row.rank_change(query.rank_change_days) is None,
-(row.rank_change(query.rank_change_days) or 0),
row.observation.sector_code,
),
)
)
else:
selected = select_rank_change_side(
metric_rows,
days=query.rank_change_days,
side=query.side,
)
else:
selected = select_percentile_side(metric_rows, query.side)
if query.side is not RankSide.BOTTOM:
selected = tuple(
sorted(
selected,
key=lambda row: (
row.rank_position is None,
row.rank_position or 0,
row.observation.sector_code,
),
)
)
search = query.search.strip().casefold() if query.search else ""
searched = tuple(
row
for row in selected
if not search
or search in row.observation.sector_code.casefold()
or search in row.observation.sector_name.casefold()
)
start = (query.page - 1) * query.page_size
return RankingPage(
status="success",
query=query,
publication=publication,
definition=definition,
rows=searched[start : start + query.page_size],
total=len(searched),
)
__all__ = [
"RadarDateIndex",
"RadarMetricDefinition",
"RadarQuery",
"RadarView",
"RankingPage",
"ReadSectorRadar",
]
@@ -226,8 +226,14 @@ class SectorRadarRepository(Protocol):
self, target_trade_date: date | None = None self, target_trade_date: date | None = None
) -> RadarPublication | None: ... ) -> RadarPublication | None: ...
def get_successful_publication(self, target_trade_date: date) -> RadarPublication | None: ...
def get_latest_publication(self) -> RadarPublication | None: ...
def list_successful_dates(self) -> Sequence[date]: ... def list_successful_dates(self) -> Sequence[date]: ...
def load_rankings(self, publication_id: str) -> Sequence[RankedMetric]: ...
def load_daily_aggregate_history( def load_daily_aggregate_history(
self, target_trade_date: date, *, limit_dates: int self, target_trade_date: date, *, limit_dates: int
) -> Sequence[SectorDailyAggregate]: ... ) -> Sequence[SectorDailyAggregate]: ...
@@ -279,7 +279,7 @@ class InMemorySectorRadarRepository:
) -> RadarPublication | None: ) -> RadarPublication | None:
"""Find an identical success or partial revision without hiding failures.""" """Find an identical success or partial revision without hiding failures."""
return next( return max(
( (
item item
for item in self.publications.values() for item in self.publications.values()
@@ -287,7 +287,8 @@ class InMemorySectorRadarRepository:
and item.target_trade_date == target_trade_date and item.target_trade_date == target_trade_date
and item.input_hash == input_hash and item.input_hash == input_hash
), ),
None, key=lambda item: (item.finished_at or item.started_at, item.publication_id),
default=None,
) )
def get_last_good_publication( def get_last_good_publication(
@@ -303,7 +304,35 @@ class InMemorySectorRadarRepository:
) )
return max( return max(
candidates, candidates,
key=lambda item: (item.target_trade_date, item.finished_at or item.started_at), key=lambda item: (
item.target_trade_date,
item.finished_at or item.started_at,
item.publication_id,
),
default=None,
)
def get_successful_publication(self, target_trade_date: date) -> RadarPublication | None:
"""Return the latest successful revision for exactly one date."""
candidates = tuple(
publication
for publication in self.publications.values()
if publication.status is PublicationStatus.SUCCESS
and publication.target_trade_date == target_trade_date
)
return max(
candidates,
key=lambda item: (item.finished_at or item.started_at, item.publication_id),
default=None,
)
def get_latest_publication(self) -> RadarPublication | None:
"""Return the newest build attempt regardless of terminal status."""
return max(
self.publications.values(),
key=lambda item: (item.target_trade_date, item.started_at, item.publication_id),
default=None, default=None,
) )
@@ -321,6 +350,26 @@ class InMemorySectorRadarRepository:
) )
) )
def load_rankings(self, publication_id: str) -> Sequence[RankedMetric]:
"""Load every ranking projection owned by one publication."""
return tuple(
sorted(
(
record.ranking
for record in self.rankings.values()
if record.publication_id == publication_id
),
key=lambda row: (
row.observation.sector_type.value,
row.observation.metric_version,
row.rank_position is None,
row.rank_position or 0,
row.observation.sector_code,
),
)
)
def load_daily_aggregate_history( def load_daily_aggregate_history(
self, target_trade_date: date, *, limit_dates: int self, target_trade_date: date, *, limit_dates: int
) -> Sequence[SectorDailyAggregate]: ) -> Sequence[SectorDailyAggregate]:
@@ -378,7 +427,7 @@ class InMemorySectorRadarRepository:
for item in self.publications.values() for item in self.publications.values()
if item.status is PublicationStatus.SUCCESS and item.target_trade_date == trade_date if item.status is PublicationStatus.SUCCESS and item.target_trade_date == trade_date
), ),
key=lambda item: item.finished_at or item.started_at, key=lambda item: (item.finished_at or item.started_at, item.publication_id),
) )
@staticmethod @staticmethod
@@ -645,7 +645,7 @@ class PostgresSectorRadarRepository:
row = connection.execute( row = connection.execute(
self._publication_select() + " WHERE target_trade_date = %s AND input_hash = %s " self._publication_select() + " WHERE target_trade_date = %s AND input_hash = %s "
"AND status IN ('success', 'partial') " "AND status IN ('success', 'partial') "
"ORDER BY finished_at DESC, created_at DESC, id DESC LIMIT 1", "ORDER BY finished_at DESC, id DESC LIMIT 1",
(target_trade_date, input_hash), (target_trade_date, input_hash),
).fetchone() ).fetchone()
return None if row is None else self._publication_from_row(row) return None if row is None else self._publication_from_row(row)
@@ -663,12 +663,33 @@ class PostgresSectorRadarRepository:
query = ( query = (
self._publication_select() self._publication_select()
+ where + where
+ " ORDER BY target_trade_date DESC, finished_at DESC, created_at DESC, id DESC LIMIT 1" + " ORDER BY target_trade_date DESC, finished_at DESC, id DESC LIMIT 1"
) )
with self._connection() as connection: with self._connection() as connection:
row = connection.execute(query, parameters).fetchone() row = connection.execute(query, parameters).fetchone()
return None if row is None else self._publication_from_row(row) return None if row is None else self._publication_from_row(row)
def get_successful_publication(self, target_trade_date: date) -> RadarPublication | None:
"""Read the latest successful revision for exactly one target date."""
with self._connection() as connection:
row = connection.execute(
self._publication_select() + " WHERE status = 'success' AND target_trade_date = %s "
"ORDER BY finished_at DESC, id DESC LIMIT 1",
(target_trade_date,),
).fetchone()
return None if row is None else self._publication_from_row(row)
def get_latest_publication(self) -> RadarPublication | None:
"""Read the newest build attempt regardless of terminal status."""
with self._connection() as connection:
row = connection.execute(
self._publication_select()
+ " ORDER BY target_trade_date DESC, started_at DESC, id DESC LIMIT 1"
).fetchone()
return None if row is None else self._publication_from_row(row)
def list_successful_dates(self) -> Sequence[date]: def list_successful_dates(self) -> Sequence[date]:
"""List distinct successful target dates newest first.""" """List distinct successful target dates newest first."""
@@ -683,6 +704,24 @@ class PostgresSectorRadarRepository:
).fetchall() ).fetchall()
return tuple(row[0] for row in rows) return tuple(row[0] for row in rows)
def load_rankings(self, publication_id: str) -> Sequence[RankedMetric]:
"""Load all ranking projections for one publication in deterministic order."""
with self._connection() as connection:
rows = connection.execute(
"""
SELECT trade_date, sector_type, sector_code, sector_name, metric_kind,
metric_version, implementation_kind, unit, metric_value, quality,
member_count, valid_sample_count, membership_coverage,
moneyflow_coverage, rank_position, rank_percentile, rank_changes
FROM sector_radar_ranking
WHERE publication_id = %s
ORDER BY sector_type, metric_version, rank_position NULLS LAST, sector_code
""",
(publication_id,),
).fetchall()
return tuple(self._ranking_from_row(row) for row in rows)
def load_daily_aggregate_history( def load_daily_aggregate_history(
self, target_trade_date: date, *, limit_dates: int self, target_trade_date: date, *, limit_dates: int
) -> Sequence[SectorDailyAggregate]: ) -> Sequence[SectorDailyAggregate]:
@@ -697,7 +736,7 @@ class PostgresSectorRadarRepository:
SELECT DISTINCT ON (target_trade_date) id, target_trade_date SELECT DISTINCT ON (target_trade_date) id, target_trade_date
FROM sector_radar_publication FROM sector_radar_publication
WHERE status = 'success' AND target_trade_date < %s WHERE status = 'success' AND target_trade_date < %s
ORDER BY target_trade_date DESC, finished_at DESC, created_at DESC, id DESC ORDER BY target_trade_date DESC, finished_at DESC, id DESC
LIMIT %s LIMIT %s
) )
SELECT aggregate.trade_date, aggregate.sector_type, aggregate.sector_code, SELECT aggregate.trade_date, aggregate.sector_type, aggregate.sector_code,
@@ -727,7 +766,7 @@ class PostgresSectorRadarRepository:
SELECT DISTINCT ON (target_trade_date) id, target_trade_date SELECT DISTINCT ON (target_trade_date) id, target_trade_date
FROM sector_radar_publication FROM sector_radar_publication
WHERE status = 'success' AND target_trade_date < %s WHERE status = 'success' AND target_trade_date < %s
ORDER BY target_trade_date DESC, finished_at DESC, created_at DESC, id DESC ORDER BY target_trade_date DESC, finished_at DESC, id DESC
LIMIT %s LIMIT %s
) )
SELECT selected.target_trade_date, ranking.trade_date, ranking.sector_type, SELECT selected.target_trade_date, ranking.trade_date, ranking.sector_type,
@@ -0,0 +1,302 @@
"""HTTP presentation for persisted sector radar rankings."""
from __future__ import annotations
import atexit
import threading
from datetime import date, datetime
from decimal import Decimal
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from ....bootstrap.config import Settings, get_settings
from ..application.read import (
RadarDateIndex,
RadarMetricDefinition,
RadarQuery,
RadarView,
RankingPage,
ReadSectorRadar,
)
from ..domain.models import (
MetricKind,
MetricQuality,
MetricUnit,
PublicationStatus,
RadarPublication,
RankedMetric,
RankSide,
SectorType,
)
from ..infrastructure.postgres import (
PostgresSectorRadarRepository,
SectorRadarRepositoryError,
)
sector_radar_router = APIRouter()
_REPOSITORY_CACHE_LOCK = threading.Lock()
_REPOSITORY_CACHE: dict[tuple[str, int], PostgresSectorRadarRepository] = {}
class RadarPublicationResponse(BaseModel):
"""Safe publication provenance and quality metadata."""
publication_id: str
target_trade_date: date
status: PublicationStatus
source_version: str
universe_version: str
metric_versions: list[str]
input_hash: str | None
coverage: Decimal = Field(ge=0, le=1)
started_at: datetime
finished_at: datetime | None
error_summary: str | None
class RadarDatesResponse(BaseModel):
"""Successful dates plus newest attempt and strict last-good metadata."""
status: Literal["success", "no_data"]
available_dates: list[date]
current_attempt: RadarPublicationResponse | None
last_good: RadarPublicationResponse | None
class RadarMetricDefinitionResponse(BaseModel):
"""Version and labeling for one independent metric implementation."""
metric_kind: MetricKind
metric_version: str
label: str
unit: MetricUnit
implementation_kind: Literal["independent"]
disclaimer: str
class RadarRankingRowResponse(BaseModel):
"""One sector ranking row with explicit null and unit semantics."""
trade_date: date
sector_type: SectorType
sector_code: str
sector_name: str
metric_kind: MetricKind
metric_version: str
implementation_kind: Literal["independent"]
unit: MetricUnit
metric_value: Decimal | None
quality: MetricQuality
member_count: int = Field(ge=0)
valid_sample_count: int = Field(ge=0)
membership_coverage: Decimal = Field(ge=0, le=1)
moneyflow_coverage: Decimal = Field(ge=0, le=1)
rank_position: int | None = Field(default=None, ge=1)
rank_percentile: Decimal | None = Field(default=None, ge=0, le=100)
rank_change_days: int = Field(ge=1, le=5)
rank_change: int | None
def _empty_ranking_rows() -> list[RadarRankingRowResponse]:
return []
class RadarRankingsResponse(BaseModel):
"""One persisted, filtered ranking page."""
status: Literal["success", "no_data"]
requested_trade_date: date | None
sector_type: SectorType
view: RadarView
rank_change_metric: MetricKind
rank_change_days: int = Field(ge=1, le=5)
side: RankSide
search: str | None
publication: RadarPublicationResponse | None
definition: RadarMetricDefinitionResponse
page: int = Field(ge=1)
page_size: int = Field(ge=1, le=100)
total: int = Field(ge=0)
rows: list[RadarRankingRowResponse] = Field(default_factory=_empty_ranking_rows)
def get_sector_radar_reader(
settings: Annotated[Settings, Depends(get_settings)],
) -> ReadSectorRadar:
"""Return a reader backed by one process-cached PostgreSQL repository."""
key = (settings.database_url, settings.sector_radar_advisory_lock_key)
with _REPOSITORY_CACHE_LOCK:
repository = _REPOSITORY_CACHE.get(key)
if repository is None:
repository = PostgresSectorRadarRepository(
settings.database_url,
advisory_lock_key=settings.sector_radar_advisory_lock_key,
)
_REPOSITORY_CACHE[key] = repository
return ReadSectorRadar(repository)
def _close_cached_repositories() -> None:
"""Close process-owned radar pools during interpreter shutdown."""
with _REPOSITORY_CACHE_LOCK:
repositories = tuple(_REPOSITORY_CACHE.values())
_REPOSITORY_CACHE.clear()
for repository in repositories:
repository.close()
atexit.register(_close_cached_repositories)
@sector_radar_router.get("/dates", response_model=RadarDatesResponse)
def get_sector_radar_dates(
reader: Annotated[ReadSectorRadar, Depends(get_sector_radar_reader)],
) -> RadarDatesResponse:
"""Return persisted availability without invoking Tushare."""
try:
return _dates_response(reader.list_dates())
except SectorRadarRepositoryError as exc:
raise _storage_error() from exc
@sector_radar_router.get("/rankings", response_model=RadarRankingsResponse)
def get_sector_radar_rankings(
reader: Annotated[ReadSectorRadar, Depends(get_sector_radar_reader)],
trade_date: date | None = None,
sector_type: SectorType = SectorType.CONCEPT,
view: RadarView = RadarView.AMOUNT,
rank_change_metric: MetricKind = MetricKind.AMOUNT,
rank_change_days: Annotated[int, Query(ge=1, le=5)] = 1,
side: RankSide = RankSide.ALL,
search: Annotated[str | None, Query(max_length=100)] = None,
page: Annotated[int, Query(ge=1)] = 1,
page_size: Annotated[int, Query(ge=1, le=100)] = 20,
) -> RadarRankingsResponse:
"""Return one filtered page from a successful publication."""
query = RadarQuery(
trade_date=trade_date,
sector_type=sector_type,
view=view,
rank_change_metric=rank_change_metric,
rank_change_days=rank_change_days,
side=side,
search=search.strip() or None if search else None,
page=page,
page_size=page_size,
)
try:
return _rankings_response(reader.query(query))
except SectorRadarRepositoryError as exc:
raise _storage_error() from exc
def _dates_response(index: RadarDateIndex) -> RadarDatesResponse:
return RadarDatesResponse(
status=index.status,
available_dates=list(index.available_dates),
current_attempt=(
_publication_response(index.current_attempt)
if index.current_attempt is not None
else None
),
last_good=(_publication_response(index.last_good) if index.last_good is not None else None),
)
def _rankings_response(page: RankingPage) -> RadarRankingsResponse:
query = page.query
return RadarRankingsResponse(
status=page.status,
requested_trade_date=query.trade_date,
sector_type=query.sector_type,
view=query.view,
rank_change_metric=query.rank_change_metric,
rank_change_days=query.rank_change_days,
side=query.side,
search=query.search,
publication=(
_publication_response(page.publication) if page.publication is not None else None
),
definition=_definition_response(page.definition),
page=query.page,
page_size=query.page_size,
total=page.total,
rows=[_ranking_response(row, query.rank_change_days) for row in page.rows],
)
def _publication_response(publication: RadarPublication) -> RadarPublicationResponse:
return RadarPublicationResponse(
publication_id=publication.publication_id,
target_trade_date=publication.target_trade_date,
status=publication.status,
source_version=publication.source_version,
universe_version=publication.universe_version,
metric_versions=list(publication.metric_versions),
input_hash=publication.input_hash,
coverage=publication.coverage,
started_at=publication.started_at,
finished_at=publication.finished_at,
error_summary=publication.error_summary,
)
def _definition_response(
definition: RadarMetricDefinition,
) -> RadarMetricDefinitionResponse:
return RadarMetricDefinitionResponse(
metric_kind=definition.metric_kind,
metric_version=definition.metric_version,
label=definition.label,
unit=definition.unit,
implementation_kind=definition.implementation_kind,
disclaimer=definition.disclaimer,
)
def _ranking_response(row: RankedMetric, rank_change_days: int) -> RadarRankingRowResponse:
observation = row.observation
return RadarRankingRowResponse(
trade_date=observation.trade_date,
sector_type=observation.sector_type,
sector_code=observation.sector_code,
sector_name=observation.sector_name,
metric_kind=observation.metric_kind,
metric_version=observation.metric_version,
implementation_kind=observation.implementation_kind,
unit=observation.unit,
metric_value=observation.value,
quality=observation.quality,
member_count=observation.member_count,
valid_sample_count=observation.valid_sample_count,
membership_coverage=observation.membership_coverage,
moneyflow_coverage=observation.moneyflow_coverage,
rank_position=row.rank_position,
rank_percentile=row.rank_percentile,
rank_change_days=rank_change_days,
rank_change=row.rank_change(rank_change_days),
)
def _storage_error() -> HTTPException:
return HTTPException(
status_code=503,
detail={
"code": "sector_radar_storage_unavailable",
"message": "sector radar storage is unavailable",
},
)
__all__ = [
"RadarDatesResponse",
"RadarRankingsResponse",
"get_sector_radar_reader",
"sector_radar_router",
]
@@ -0,0 +1,219 @@
from dataclasses import replace
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
from fastapi.testclient import TestClient
from zhixing_server.bootstrap.app import create_app
from zhixing_server.modules.sector_radar.application.read import (
RadarDateIndex,
RadarMetricDefinition,
RadarQuery,
RadarView,
RankingPage,
)
from zhixing_server.modules.sector_radar.domain.metrics import AmountNetStrategy
from zhixing_server.modules.sector_radar.domain.models import (
MetricKind,
MetricObservation,
MetricQuality,
MetricUnit,
PublicationStatus,
RadarPublication,
RankChange,
RankedMetric,
RankSide,
SectorType,
)
from zhixing_server.modules.sector_radar.infrastructure.postgres import (
SectorRadarRepositoryError,
)
from zhixing_server.modules.sector_radar.presentation.http import (
get_sector_radar_reader,
)
TARGET_DATE = date(2026, 8, 28)
NOW = datetime(2026, 8, 28, 17, 30, tzinfo=UTC)
def _publication(
publication_id: str,
status: PublicationStatus,
*,
trade_date: date = TARGET_DATE,
) -> RadarPublication:
return RadarPublication(
publication_id=publication_id,
target_trade_date=trade_date,
status=status,
source_version="tushare-pro-v1",
universe_version="eastmoney-dc-v1",
metric_versions=(AmountNetStrategy.metric_version,),
input_hash="a" * 64 if status is PublicationStatus.SUCCESS else None,
coverage=Decimal(1) if status is PublicationStatus.SUCCESS else Decimal("0.8"),
started_at=NOW,
finished_at=None if status is PublicationStatus.RUNNING else NOW + timedelta(minutes=5),
error_summary=None if status is PublicationStatus.SUCCESS else "safe_error",
)
def _ranking() -> RankedMetric:
return RankedMetric(
observation=MetricObservation(
trade_date=TARGET_DATE,
sector_type=SectorType.CONCEPT,
sector_code="BK0001.DC",
sector_name="机器人",
metric_kind=MetricKind.AMOUNT,
metric_version=AmountNetStrategy.metric_version,
implementation_kind="independent",
unit=MetricUnit.CNY_100M,
value=Decimal("12.5"),
quality=MetricQuality.AVAILABLE,
member_count=20,
valid_sample_count=19,
membership_coverage=Decimal(1),
moneyflow_coverage=Decimal("0.95"),
),
rank_position=1,
rank_percentile=Decimal(100),
rank_changes=(RankChange(days=5, value=3),),
)
class FakeReader:
def __init__(self, *, no_data: bool = False, fail: bool = False) -> None:
self.fail = fail
self.last_query: RadarQuery | None = None
success = _publication("publication-success", PublicationStatus.SUCCESS)
current = _publication(
"publication-partial",
PublicationStatus.PARTIAL,
trade_date=TARGET_DATE + timedelta(days=1),
)
self.date_index = RadarDateIndex(
available_dates=() if no_data else (TARGET_DATE,),
current_attempt=None if no_data else current,
last_good=None if no_data else success,
)
query = RadarQuery()
self.page = RankingPage(
status="no_data" if no_data else "success",
query=query,
publication=None if no_data else success,
definition=RadarMetricDefinition(
metric_kind=MetricKind.AMOUNT,
metric_version=AmountNetStrategy.metric_version,
label="主力净流入(知行独立实现)",
unit=MetricUnit.CNY_100M,
),
rows=() if no_data else (_ranking(),),
total=0 if no_data else 1,
)
def list_dates(self) -> RadarDateIndex:
if self.fail:
raise SectorRadarRepositoryError("private database detail")
return self.date_index
def query(self, query: RadarQuery) -> RankingPage:
if self.fail:
raise SectorRadarRepositoryError("private database detail")
self.last_query = query
return replace(self.page, query=query)
def _client(reader: FakeReader) -> TestClient:
application = create_app()
application.dependency_overrides[get_sector_radar_reader] = lambda: reader
return TestClient(application)
def test_dates_exposes_partial_attempt_without_replacing_last_good() -> None:
response = _client(FakeReader()).get("/api/v1/sector-radar/dates")
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "success"
assert payload["available_dates"] == ["2026-08-28"]
assert payload["current_attempt"]["status"] == "partial"
assert payload["last_good"]["status"] == "success"
assert payload["last_good"]["coverage"] == "1"
def test_rankings_maps_filters_and_independent_metric_contract() -> None:
reader = FakeReader()
response = _client(reader).get(
"/api/v1/sector-radar/rankings",
params={
"trade_date": "2026-08-28",
"sector_type": "concept",
"view": "rank_change",
"rank_change_metric": "amount",
"rank_change_days": 5,
"side": "top",
"search": " 机器人 ",
"page": 2,
"page_size": 10,
},
)
assert response.status_code == 200
assert reader.last_query == RadarQuery(
trade_date=TARGET_DATE,
sector_type=SectorType.CONCEPT,
view=RadarView.RANK_CHANGE,
rank_change_metric=MetricKind.AMOUNT,
rank_change_days=5,
side=RankSide.TOP,
search="机器人",
page=2,
page_size=10,
)
payload = response.json()
assert payload["definition"]["metric_version"] == "zhixing_amount_net_bn_v1"
assert payload["definition"]["implementation_kind"] == "independent"
assert "知行独立实现" in payload["definition"]["disclaimer"]
assert payload["rows"][0]["rank_change"] == 3
assert payload["rows"][0]["unit"] == "CNY_100M"
def test_no_data_is_a_stable_200_response() -> None:
client = _client(FakeReader(no_data=True))
dates = client.get("/api/v1/sector-radar/dates")
rankings = client.get("/api/v1/sector-radar/rankings")
assert dates.status_code == 200
assert dates.json()["status"] == "no_data"
assert rankings.status_code == 200
assert rankings.json()["status"] == "no_data"
assert rankings.json()["rows"] == []
def test_invalid_query_values_return_422() -> None:
client = _client(FakeReader())
for params in (
{"rank_change_days": 0},
{"rank_change_days": 6},
{"page": 0},
{"page_size": 101},
{"sector_type": "region"},
{"view": "unknown"},
{"side": "unknown"},
):
assert client.get("/api/v1/sector-radar/rankings", params=params).status_code == 422
def test_repository_error_maps_to_redacted_503() -> None:
response = _client(FakeReader(fail=True)).get("/api/v1/sector-radar/rankings")
assert response.status_code == 503
assert response.json() == {
"detail": {
"code": "sector_radar_storage_unavailable",
"message": "sector radar storage is unavailable",
}
}
assert "private database detail" not in response.text
@@ -15,12 +15,20 @@ TARGET_DATE = date(2026, 8, 28)
class FakeResult: class FakeResult:
def __init__(self, row: tuple[object, ...] | None = None) -> None: def __init__(
self,
row: tuple[object, ...] | None = None,
rows: tuple[tuple[object, ...], ...] | None = None,
) -> None:
self.row = row self.row = row
self.rows = rows or (() if row is None else (row,))
def fetchone(self) -> tuple[object, ...] | None: def fetchone(self) -> tuple[object, ...] | None:
return self.row return self.row
def fetchall(self) -> tuple[tuple[object, ...], ...]:
return self.rows
class FakeConnection: class FakeConnection:
def __init__(self) -> None: def __init__(self) -> None:
@@ -32,6 +40,30 @@ class FakeConnection:
parameters: tuple[object, ...] = (), parameters: tuple[object, ...] = (),
) -> FakeResult: ) -> FakeResult:
self.statements.append((query, parameters)) self.statements.append((query, parameters))
if "FROM sector_radar_ranking" in query:
return FakeResult(
rows=(
(
TARGET_DATE,
"concept",
"BK0001.DC",
"机器人",
"amount",
"zhixing_amount_net_bn_v1",
"independent",
"CNY_100M",
Decimal("12.5"),
"available",
20,
19,
Decimal(1),
Decimal("0.95"),
1,
Decimal(100),
{"1": 3, "2": None},
),
)
)
if "FROM sector_radar_publication" in query: if "FROM sector_radar_publication" in query:
return FakeResult( return FakeResult(
( (
@@ -100,3 +132,39 @@ def test_advisory_lock_uses_target_date_and_releases_same_key() -> None:
assert "2026-08-28" in str(connection.statements[0][1][0]) assert "2026-08-28" in str(connection.statements[0][1][0])
assert "pg_advisory_unlock" in connection.statements[1][0] assert "pg_advisory_unlock" in connection.statements[1][0]
assert connection.statements[0][1] == connection.statements[1][1] assert connection.statements[0][1] == connection.statements[1][1]
def test_exact_success_and_latest_attempt_queries_use_distinct_semantics() -> None:
connection = FakeConnection()
repository = make_repository(connection)
exact = repository.get_successful_publication(TARGET_DATE)
latest = repository.get_latest_publication()
assert exact is not None
assert latest is not None
exact_query, exact_parameters = connection.statements[0]
latest_query, latest_parameters = connection.statements[1]
assert "status = 'success' AND target_trade_date = %s" in exact_query
assert exact_parameters == (TARGET_DATE,)
assert "status = 'success'" not in latest_query
assert "started_at DESC" in latest_query
assert latest_parameters == ()
def test_load_rankings_reconstructs_values_and_rank_changes() -> None:
connection = FakeConnection()
rankings = make_repository(connection).load_rankings("publication-a")
assert len(rankings) == 1
ranking = rankings[0]
assert ranking.observation.metric_version == "zhixing_amount_net_bn_v1"
assert ranking.observation.value == Decimal("12.5")
assert ranking.rank_position == 1
assert ranking.rank_change(1) == 3
assert ranking.rank_change(2) is None
query, parameters = connection.statements[0]
assert "WHERE publication_id = %s" in query
assert "rank_position NULLS LAST" in query
assert parameters == ("publication-a",)
@@ -0,0 +1,189 @@
from dataclasses import replace
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
from zhixing_server.modules.sector_radar.application.read import (
RadarQuery,
RadarView,
ReadSectorRadar,
)
from zhixing_server.modules.sector_radar.domain.metrics import AmountNetStrategy
from zhixing_server.modules.sector_radar.domain.models import (
MetricKind,
MetricObservation,
MetricQuality,
MetricUnit,
PublicationStatus,
RadarPublication,
RankChange,
RankedMetric,
RankSide,
SectorType,
)
from zhixing_server.modules.sector_radar.domain.persistence import RankingRecord
from zhixing_server.modules.sector_radar.domain.ranking import rank_metric_observations
from zhixing_server.modules.sector_radar.infrastructure.memory import (
InMemorySectorRadarRepository,
)
TARGET_DATE = date(2026, 8, 28)
NOW = datetime(2026, 8, 28, 17, 30, tzinfo=UTC)
def _running(publication_id: str, trade_date: date) -> RadarPublication:
return RadarPublication(
publication_id=publication_id,
target_trade_date=trade_date,
status=PublicationStatus.RUNNING,
source_version="tushare-pro-v1",
universe_version="eastmoney-dc-v1",
metric_versions=(AmountNetStrategy.metric_version,),
input_hash=None,
coverage=Decimal(0),
started_at=NOW,
)
def _finish(
publication: RadarPublication,
status: PublicationStatus,
) -> RadarPublication:
return replace(
publication,
status=status,
input_hash="a" * 64 if status is PublicationStatus.SUCCESS else None,
coverage=Decimal(1) if status is PublicationStatus.SUCCESS else Decimal("0.8"),
finished_at=publication.started_at + timedelta(minutes=5),
error_summary=None if status is PublicationStatus.SUCCESS else "safe_error",
)
def _amount_rankings() -> tuple[RankedMetric, ...]:
observations = tuple(
MetricObservation(
trade_date=TARGET_DATE,
sector_type=SectorType.CONCEPT,
sector_code=f"BK{index:04d}.DC",
sector_name=f"概念{index}",
metric_kind=MetricKind.AMOUNT,
metric_version=AmountNetStrategy.metric_version,
implementation_kind="independent",
unit=MetricUnit.CNY_100M,
value=Decimal(11 - index),
quality=MetricQuality.AVAILABLE,
member_count=5,
valid_sample_count=5,
membership_coverage=Decimal(1),
moneyflow_coverage=Decimal(1),
)
for index in range(1, 11)
)
rankings = rank_metric_observations(observations)
return tuple(
replace(
row,
rank_changes=tuple(
RankChange(
days=days,
value=(
None
if row.observation.sector_code == "BK0005.DC" and days == 5
else (row.rank_position or 0) - 5
),
)
for days in range(1, 6)
),
)
for row in rankings
)
def _published_repository() -> InMemorySectorRadarRepository:
repository = InMemorySectorRadarRepository()
publication = _running("publication-success", TARGET_DATE)
repository.create_publication(publication)
repository.finish_publication(_finish(publication, PublicationStatus.SUCCESS))
repository.save_rankings(
RankingRecord(publication.publication_id, ranking) for ranking in _amount_rankings()
)
return repository
def test_no_successful_publication_returns_stable_no_data() -> None:
reader = ReadSectorRadar(InMemorySectorRadarRepository())
dates = reader.list_dates()
rankings = reader.query(RadarQuery())
assert dates.status == "no_data"
assert dates.available_dates == ()
assert rankings.status == "no_data"
assert rankings.publication is None
assert rankings.total == 0
assert rankings.definition.metric_version == AmountNetStrategy.metric_version
def test_explicit_date_never_falls_back_to_an_earlier_last_good() -> None:
reader = ReadSectorRadar(_published_repository())
missing = reader.query(RadarQuery(trade_date=TARGET_DATE + timedelta(days=1)))
assert missing.status == "no_data"
assert missing.publication is None
def test_percentile_side_is_selected_before_search_and_pagination() -> None:
reader = ReadSectorRadar(_published_repository())
top = reader.query(RadarQuery(side=RankSide.TOP, page_size=1))
second_page = reader.query(RadarQuery(side=RankSide.TOP, page=2, page_size=1))
searched = reader.query(RadarQuery(side=RankSide.TOP, search="概念2"))
bottom = reader.query(RadarQuery(side=RankSide.BOTTOM))
assert top.total == 2
assert top.rows[0].observation.sector_code == "BK0001.DC"
assert second_page.rows[0].observation.sector_code == "BK0002.DC"
assert searched.total == 1
assert searched.rows[0].observation.sector_name == "概念2"
assert bottom.total == 1
assert bottom.rows[0].observation.sector_code == "BK0010.DC"
def test_rank_change_uses_selected_metric_days_and_pool_sides() -> None:
reader = ReadSectorRadar(_published_repository())
query = RadarQuery(
view=RadarView.RANK_CHANGE,
rank_change_metric=MetricKind.AMOUNT,
rank_change_days=5,
)
top = reader.query(replace(query, side=RankSide.TOP))
bottom = reader.query(replace(query, side=RankSide.BOTTOM))
all_rows = reader.query(query)
assert top.total == 1
assert top.rows[0].rank_change(5) == 5
assert bottom.total == 1
assert bottom.rows[0].rank_change(5) == -4
assert all_rows.total == 10
assert all_rows.rows[-1].observation.sector_code == "BK0005.DC"
assert all_rows.rows[-1].rank_change(5) is None
def test_latest_partial_attempt_is_visible_but_does_not_replace_last_good() -> None:
repository = _published_repository()
partial = replace(
_running("publication-partial", TARGET_DATE + timedelta(days=1)),
started_at=NOW + timedelta(days=1),
)
repository.create_publication(partial)
repository.finish_publication(_finish(partial, PublicationStatus.PARTIAL))
index = ReadSectorRadar(repository).list_dates()
assert index.status == "success"
assert index.current_attempt is not None
assert index.current_attempt.status is PublicationStatus.PARTIAL
assert index.last_good is not None
assert index.last_good.publication_id == "publication-success"
assert index.available_dates == (TARGET_DATE,)