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,)