190 lines
6.5 KiB
Python
190 lines
6.5 KiB
Python
|
|
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,)
|