feat(sector-radar): add weighted scores and rank-change views
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user