220 lines
7.2 KiB
Python
220 lines
7.2 KiB
Python
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
|