from dataclasses import replace from datetime import UTC, date, datetime, timedelta from decimal import Decimal import pytest from zhixing_server.modules.sector_radar.application.read import ( RadarQuery, RadarView, ReadSectorRadar, SectorRef, StockSectorQuery, ) 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 ( MembershipRecord, RankingRecord, SectorCountEntry, ) 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,) def _membership_record( stock_code: str, sector_type: SectorType, sector_code: str, sector_name: str, *, trade_date: date = TARGET_DATE, ) -> MembershipRecord: return MembershipRecord( source_snapshot_id="b" * 64, trade_date=trade_date, sector_type=sector_type, sector_code=sector_code, sector_name=sector_name, stock_code=stock_code, stock_name="测试股票", ) def _membership_repository() -> InMemorySectorRadarRepository: repository = _published_repository() repository.save_memberships( ( _membership_record("000001.SZ", SectorType.INDUSTRY, "BK0902.DC", "房地产"), _membership_record("000001.SZ", SectorType.CONCEPT, "BK0003.DC", "数字经济"), _membership_record("000001.SZ", SectorType.CONCEPT, "BK0001.DC", "机器人"), _membership_record("000001.SZ", SectorType.CONCEPT, "BK0002.DC", "人工智能"), _membership_record("000002.SZ", SectorType.INDUSTRY, "BK0901.DC", "银行"), ) ) return repository def test_stock_membership_groups_types_and_orders_by_sector_code() -> None: reader = ReadSectorRadar(_membership_repository()) membership = reader.stock_membership( StockSectorQuery(trade_date=TARGET_DATE, ts_code="000001.SZ") ) assert membership.status == "success" assert membership.trade_date == TARGET_DATE assert membership.industries == (SectorRef(code="BK0902.DC", name="房地产"),) assert [ref.code for ref in membership.concepts] == [ "BK0001.DC", "BK0002.DC", "BK0003.DC", ] assert membership.concept_total == 3 other = reader.stock_membership(StockSectorQuery(trade_date=TARGET_DATE, ts_code="000002.SZ")) assert other.industries == (SectorRef(code="BK0901.DC", name="银行"),) assert other.concepts == () assert other.concept_total == 0 def test_stock_membership_truncates_concepts_with_total_preserved() -> None: reader = ReadSectorRadar(_membership_repository()) membership = reader.stock_membership( StockSectorQuery(trade_date=TARGET_DATE, ts_code="000001.SZ", concept_limit=2) ) assert [ref.code for ref in membership.concepts] == ["BK0001.DC", "BK0002.DC"] assert membership.concept_total == 3 def test_stock_membership_uses_newest_build_at_or_before_requested_date() -> None: repository = _membership_repository() earlier = _running("publication-earlier", TARGET_DATE - timedelta(days=1)) repository.create_publication(earlier) repository.finish_publication(_finish(earlier, PublicationStatus.SUCCESS)) repository.save_memberships( ( _membership_record( "000001.SZ", SectorType.INDUSTRY, "BK0899.DC", "旧行业", trade_date=TARGET_DATE - timedelta(days=1), ), ) ) reader = ReadSectorRadar(repository) membership = reader.stock_membership( StockSectorQuery(trade_date=TARGET_DATE, ts_code="000001.SZ") ) assert membership.trade_date == TARGET_DATE assert membership.industries == (SectorRef(code="BK0902.DC", name="房地产"),) behind = reader.stock_membership( StockSectorQuery(trade_date=TARGET_DATE - timedelta(days=1), ts_code="000001.SZ") ) assert behind.trade_date == TARGET_DATE - timedelta(days=1) assert behind.industries == (SectorRef(code="BK0899.DC", name="旧行业"),) assert behind.concepts == () def test_stock_membership_without_any_publication_is_no_data() -> None: reader = ReadSectorRadar(InMemorySectorRadarRepository()) membership = reader.stock_membership( StockSectorQuery(trade_date=TARGET_DATE, ts_code="000001.SZ") ) assert membership.status == "no_data" assert membership.trade_date is None assert membership.industries == () assert membership.concepts == () assert membership.concept_total == 0 def test_stock_membership_query_rejects_invalid_values() -> None: with pytest.raises(ValueError): StockSectorQuery(trade_date=TARGET_DATE, ts_code=" ") with pytest.raises(ValueError): StockSectorQuery(trade_date=TARGET_DATE, ts_code="x" * 13) with pytest.raises(ValueError): StockSectorQuery(trade_date=TARGET_DATE, ts_code="000001.SZ", concept_limit=0) def test_sector_counts_aggregates_concepts_and_orders_by_count_then_code() -> None: repository = _membership_repository() repository.save_memberships( ( _membership_record("000002.SZ", SectorType.CONCEPT, "BK0001.DC", "机器人"), _membership_record("000003.SZ", SectorType.CONCEPT, "BK0001.DC", "机器人"), _membership_record("000002.SZ", SectorType.CONCEPT, "BK0003.DC", "数字经济"), ) ) reader = ReadSectorRadar(repository) snapshot = reader.sector_counts(["000001.SZ", "000002.SZ", "000003.SZ"], TARGET_DATE) assert snapshot.status == "success" assert snapshot.trade_date == TARGET_DATE assert snapshot.sector_type is SectorType.CONCEPT assert snapshot.counts == ( SectorCountEntry(sector_code="BK0001.DC", sector_name="机器人", stock_count=3), SectorCountEntry(sector_code="BK0003.DC", sector_name="数字经济", stock_count=2), SectorCountEntry(sector_code="BK0002.DC", sector_name="人工智能", stock_count=1), ) def test_sector_counts_supports_industry_type() -> None: reader = ReadSectorRadar(_membership_repository()) snapshot = reader.sector_counts( ["000001.SZ", "000002.SZ"], TARGET_DATE, sector_type=SectorType.INDUSTRY, ) assert snapshot.status == "success" assert snapshot.sector_type is SectorType.INDUSTRY assert snapshot.counts == ( SectorCountEntry(sector_code="BK0901.DC", sector_name="银行", stock_count=1), SectorCountEntry(sector_code="BK0902.DC", sector_name="房地产", stock_count=1), ) def test_sector_counts_without_publication_is_no_data() -> None: reader = ReadSectorRadar(InMemorySectorRadarRepository()) snapshot = reader.sector_counts(["000001.SZ"], TARGET_DATE) assert snapshot.status == "no_data" assert snapshot.trade_date is None assert snapshot.counts == () def test_sector_counts_with_empty_stock_set_skips_publication_lookup() -> None: reader = ReadSectorRadar(_membership_repository()) snapshot = reader.sector_counts([], TARGET_DATE) assert snapshot.status == "no_data" assert snapshot.counts == () def test_sector_member_codes_returns_available_members() -> None: reader = ReadSectorRadar(_membership_repository()) snapshot = reader.sector_member_codes(TARGET_DATE, "BK0001.DC") assert snapshot.status == "success" assert snapshot.trade_date == TARGET_DATE assert snapshot.stock_codes == ("000001.SZ",) def test_sector_member_codes_without_publication_is_no_data() -> None: reader = ReadSectorRadar(InMemorySectorRadarRepository()) snapshot = reader.sector_member_codes(TARGET_DATE, "BK0001.DC") assert snapshot.status == "no_data" assert snapshot.trade_date is None assert snapshot.stock_codes == () def test_sector_member_codes_rejects_blank_sector_code() -> None: reader = ReadSectorRadar(_membership_repository()) with pytest.raises(ValueError): reader.sector_member_codes(TARGET_DATE, " ")