feat(sector-radar): add weighted scores and rank-change views

This commit is contained in:
yuxuanhui
2026-09-21 23:57:55 +08:00
parent 669e89d3c3
commit b9981aa48d
45 changed files with 2468 additions and 320 deletions
@@ -599,10 +599,14 @@ def test_tenth_trading_day_publishes_swing_and_five_rank_changes() -> None:
swing = tuple(
ranking
for ranking in current
if ranking.observation.metric_version == "zhixing_swing_equal_3_10_v1"
if ranking.observation.metric_version == "zhixing_swing_weighted_v2"
)
assert len(swing) == 2
assert all(ranking.observation.value == Decimal("0.03") for ranking in swing)
assert all(
ranking.observation.value == pytest.approx(Decimal(150000) / Decimal(5000100))
for ranking in swing
)
assert all(ranking.observation.weighted_score is not None for ranking in swing)
assert all(
tuple(change.value for change in ranking.rank_changes) == (None, None, None, None, None)
for ranking in swing
@@ -786,7 +790,7 @@ def test_detail_history_and_ranking_extras_http_use_the_same_publication() -> No
row = ranking.json()["rows"][0]
assert row["pct_change"] == "1"
assert Decimal(row["daily_net_amount_yuan"]) == 150000
assert Decimal(row["daily_ratio"]) == Decimal("0.03")
assert Decimal(row["daily_ratio"]) == Decimal(150000) / Decimal(5000100)
assert row["on_list_count"] == row["history_available_days"] == 1
assert client.get(base + "/detail").status_code == 422
absent = client.get(base + "/detail", params={"trade_date": "2020-01-01"})
@@ -805,6 +809,13 @@ def test_postgres_detail_migration_and_build_roundtrip(monkeypatch: pytest.Monke
from zhixing_server.bootstrap.config import sqlalchemy_database_url
from zhixing_server.modules.sector_radar.application.details import ReadRadarDetails
from zhixing_server.modules.sector_radar.application.recompute import RecomputeSectorRadar
from zhixing_server.modules.sector_radar.domain.metrics import (
AmountNetStrategy,
RatioTurnoverStrategy,
SwingEqualThreeToTenStrategy,
)
from zhixing_server.modules.sector_radar.domain.models import MetricKind
from zhixing_server.modules.sector_radar.infrastructure.postgres import (
PostgresSectorRadarRepository,
)
@@ -843,7 +854,7 @@ def test_postgres_detail_migration_and_build_roundtrip(monkeypatch: pytest.Monke
assert detail.summary["amount"].metric_value == Decimal("0.0015")
with psycopg.connect(database_url) as connection:
assert connection.execute("SELECT version_num FROM alembic_version").fetchone() == (
"0009_radar_sector_detail",
"0010_radar_weighted_score",
)
row = connection.execute(
"SELECT pct_change, leading_code FROM sector_radar_daily_aggregate "
@@ -863,5 +874,140 @@ def test_postgres_detail_migration_and_build_roundtrip(monkeypatch: pytest.Monke
"SET active_buy_net_amount_yuan = 'NaN'::numeric WHERE trade_date = %s",
(target,),
)
# Exercise every ranking projection against persisted v2 scores, while
# keeping the original source publication and its NULL score readable.
start, end = target + timedelta(days=31), target + timedelta(days=46)
interval = BuildSectorRadarCommand(start_date=start, end_date=end)
source = FakeRadarSource()
legacy = BuildSectorRadar(
source,
repository,
now_fn=lambda: NOW,
strategies=(
AmountNetStrategy(),
RatioTurnoverStrategy(),
SwingEqualThreeToTenStrategy(),
),
).execute(interval)
assert legacy.status in ("success", "unchanged")
old_id = legacy.outcomes[-1].publication_id
assert old_id is not None
old_rows = tuple(repository.load_rankings(old_id))
assert all(row.observation.weighted_score is None for row in old_rows)
source.calls.clear()
rescore = RecomputeSectorRadar(repository, now_fn=lambda: NOW + timedelta(hours=1))
updated = rescore.execute(interval)
assert updated.status in ("success", "unchanged")
updated_id = updated.outcomes[-1].publication_id
assert updated_id is not None and updated_id != old_id
assert source.calls == []
assert tuple(repository.load_rankings(old_id)) == old_rows
current_rows = tuple(repository.load_rankings(updated_id))
scored = next(
row
for row in current_rows
if row.observation.metric_kind is MetricKind.SWING
and row.observation.sector_type is SectorType.CONCEPT
)
expected = ((Decimal(5000000) + 1).log10() * 100).quantize(Decimal("0.000000000001"))
assert scored.observation.weighted_score == expected
assert scored.rank_change(5) == 0
assert dict(repository.load_publication_rankings((updated_id,)))[updated_id] == current_rows
previous = dict(repository.load_previous_rankings(end + timedelta(days=1), limit_dates=1))
assert previous[end] == current_rows
historical = repository.load_ranked_history((updated_id,), ("BK0001.DC",))
assert any(row.ranking == scored for row in historical)
detail = ReadRadarDetails(repository).detail(end, SectorType.CONCEPT, "BK0001.DC")
assert detail.summary["swing"].weighted_score == expected
assert detail.pct_change == 1 and len(detail.members) == 5
assert rescore.execute(interval).status == "unchanged"
with (
psycopg.connect(database_url) as connection,
pytest.raises(psycopg.errors.CheckViolation),
connection.transaction(),
):
connection.execute(
"UPDATE sector_radar_ranking SET weighted_score = 'NaN'::numeric "
"WHERE publication_id = %s",
(updated_id,),
)
finally:
repository.close()
def test_offline_rescore_is_idempotent_preserves_old_publications_and_details() -> None:
from zhixing_server.modules.sector_radar.application.details import ReadRadarDetails
from zhixing_server.modules.sector_radar.application.read import (
RadarQuery,
RadarView,
ReadSectorRadar,
)
from zhixing_server.modules.sector_radar.application.recompute import RecomputeSectorRadar
from zhixing_server.modules.sector_radar.domain.metrics import (
AmountNetStrategy,
RatioTurnoverStrategy,
SwingEqualThreeToTenStrategy,
)
source = FakeRadarSource()
repository = InMemorySectorRadarRepository()
end = TARGET_DATE + timedelta(days=15)
command = BuildSectorRadarCommand(start_date=TARGET_DATE, end_date=end)
initial = BuildSectorRadar(
source,
repository,
now_fn=lambda: NOW,
strategies=(AmountNetStrategy(), RatioTurnoverStrategy(), SwingEqualThreeToTenStrategy()),
).execute(command)
assert initial.status == "success"
old_publications = repository.publications.copy()
old_rankings = repository.rankings.copy()
snapshot_count = len(repository.source_snapshots)
source.calls.clear()
source.fail_daily = True
service = RecomputeSectorRadar(repository, now_fn=lambda: NOW + timedelta(hours=1))
result = service.execute(command)
assert result.status == "success"
assert all(repository.publications[key] == value for key, value in old_publications.items())
assert all(repository.rankings[key] == value for key, value in old_rankings.items())
assert len(repository.source_snapshots) == snapshot_count
assert source.calls == []
latest = repository.get_last_good_publication(end)
assert latest is not None
assert "zhixing_swing_weighted_v2" in latest.metric_versions
count = len(repository.publications)
assert service.execute(command).status == "unchanged"
assert len(repository.publications) == count
reader = ReadSectorRadar(repository)
page = reader.query(RadarQuery(trade_date=end, view=RadarView.SWING))
assert len(page.rows) == 1
assert page.rows[0].observation.weighted_score is not None
assert page.rows[0].rank_change(5) == 0
detail = ReadRadarDetails(repository).detail(end, SectorType.CONCEPT, "BK0001.DC")
assert len(detail.members) == 5
assert detail.pct_change == 1
assert detail.summary["swing"].weighted_score == page.rows[0].observation.weighted_score
assert detail.summary["swing"].rank_position == page.rows[0].rank_position
def test_offline_rescore_failure_does_not_replace_last_good(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from unittest.mock import Mock
from zhixing_server.modules.sector_radar.application.recompute import RecomputeSectorRadar
repository = InMemorySectorRadarRepository()
command = BuildSectorRadarCommand(trade_date=TARGET_DATE)
BuildSectorRadar(FakeRadarSource(), repository, now_fn=lambda: NOW).execute(command)
before = repository.get_last_good_publication()
monkeypatch.setattr(
repository, "finalize_publication", Mock(side_effect=RuntimeError("injected failure"))
)
result = RecomputeSectorRadar(repository, now_fn=lambda: NOW + timedelta(hours=1)).execute(
command
)
assert result.status == "failed"
assert repository.get_last_good_publication() == before
latest = repository.get_latest_publication()
assert latest is not None and latest.status is PublicationStatus.FAILED