feat(sector-radar): 提供版本化排名查询接口
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user