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
@@ -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:
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.rows = rows or (() if row is None else (row,))
def fetchone(self) -> tuple[object, ...] | None:
return self.row
def fetchall(self) -> tuple[tuple[object, ...], ...]:
return self.rows
class FakeConnection:
def __init__(self) -> None:
@@ -32,6 +40,30 @@ class FakeConnection:
parameters: tuple[object, ...] = (),
) -> FakeResult:
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:
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 "pg_advisory_unlock" in connection.statements[1][0]
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,)