feat(sector-radar): 提供版本化排名查询接口

This commit is contained in:
yuxuanhui
2026-08-29 19:44:46 +08:00
parent d9bae722d8
commit efc4c3d530
10 changed files with 1115 additions and 12 deletions
@@ -15,12 +15,20 @@ TARGET_DATE = date(2026, 8, 28)
class FakeResult:
def __init__(self, row: tuple[object, ...] | None = None) -> None:
def __init__(
self,
row: tuple[object, ...] | None = None,
rows: tuple[tuple[object, ...], ...] | None = None,
) -> None:
self.row = row
self.rows = rows or (() if row is None else (row,))
def fetchone(self) -> tuple[object, ...] | None:
return self.row
def fetchall(self) -> tuple[tuple[object, ...], ...]:
return self.rows
class FakeConnection:
def __init__(self) -> None:
@@ -32,6 +40,30 @@ class FakeConnection:
parameters: tuple[object, ...] = (),
) -> FakeResult:
self.statements.append((query, parameters))
if "FROM sector_radar_ranking" in query:
return FakeResult(
rows=(
(
TARGET_DATE,
"concept",
"BK0001.DC",
"机器人",
"amount",
"zhixing_amount_net_bn_v1",
"independent",
"CNY_100M",
Decimal("12.5"),
"available",
20,
19,
Decimal(1),
Decimal("0.95"),
1,
Decimal(100),
{"1": 3, "2": None},
),
)
)
if "FROM sector_radar_publication" in query:
return FakeResult(
(
@@ -100,3 +132,39 @@ def test_advisory_lock_uses_target_date_and_releases_same_key() -> None:
assert "2026-08-28" in str(connection.statements[0][1][0])
assert "pg_advisory_unlock" in connection.statements[1][0]
assert connection.statements[0][1] == connection.statements[1][1]
def test_exact_success_and_latest_attempt_queries_use_distinct_semantics() -> None:
connection = FakeConnection()
repository = make_repository(connection)
exact = repository.get_successful_publication(TARGET_DATE)
latest = repository.get_latest_publication()
assert exact is not None
assert latest is not None
exact_query, exact_parameters = connection.statements[0]
latest_query, latest_parameters = connection.statements[1]
assert "status = 'success' AND target_trade_date = %s" in exact_query
assert exact_parameters == (TARGET_DATE,)
assert "status = 'success'" not in latest_query
assert "started_at DESC" in latest_query
assert latest_parameters == ()
def test_load_rankings_reconstructs_values_and_rank_changes() -> None:
connection = FakeConnection()
rankings = make_repository(connection).load_rankings("publication-a")
assert len(rankings) == 1
ranking = rankings[0]
assert ranking.observation.metric_version == "zhixing_amount_net_bn_v1"
assert ranking.observation.value == Decimal("12.5")
assert ranking.rank_position == 1
assert ranking.rank_change(1) == 3
assert ranking.rank_change(2) is None
query, parameters = connection.statements[0]
assert "WHERE publication_id = %s" in query
assert "rank_position NULLS LAST" in query
assert parameters == ("publication-a",)
@@ -0,0 +1,189 @@
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,)