feat(sector-radar): 提供版本化排名查询接口
This commit is contained in:
@@ -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,)
|
||||
Reference in New Issue
Block a user