Files
zhixing-system/zhixing-server/tests/unit/sector_radar/test_read.py
T

405 lines
14 KiB
Python
Raw Normal View History

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