fix(sector-radar): 收紧可复现发布契约

This commit is contained in:
yuxuanhui
2026-08-29 20:36:44 +08:00
parent 8e96e64151
commit 23493fa32f
17 changed files with 520 additions and 86 deletions
@@ -2,7 +2,9 @@ from dataclasses import replace
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
import pytest
from fastapi.testclient import TestClient
from pydantic import ValidationError
from zhixing_server.bootstrap.app import create_app
from zhixing_server.modules.sector_radar.application.read import (
@@ -29,6 +31,7 @@ from zhixing_server.modules.sector_radar.infrastructure.postgres import (
SectorRadarRepositoryError,
)
from zhixing_server.modules.sector_radar.presentation.http import (
RadarRankingRowResponse,
get_sector_radar_reader,
)
@@ -191,6 +194,14 @@ def test_no_data_is_a_stable_200_response() -> None:
assert rankings.json()["rows"] == []
def test_http_contract_rejects_zero_rank_percentile() -> None:
payload = _client(FakeReader()).get("/api/v1/sector-radar/rankings").json()["rows"][0]
payload["rank_percentile"] = "0"
with pytest.raises(ValidationError):
RadarRankingRowResponse.model_validate(payload)
def test_invalid_query_values_return_422() -> None:
client = _client(FakeReader())
@@ -7,6 +7,7 @@ from zhixing_server.modules.sector_radar.application.build import (
BuildSectorRadarCommand,
)
from zhixing_server.modules.sector_radar.domain.models import (
MembershipStatus,
PublicationStatus,
RadarPublication,
SectorType,
@@ -19,6 +20,7 @@ from zhixing_server.modules.sector_radar.domain.source import (
SectorIndexRow,
SectorMemberRow,
SourceResult,
SourceSnapshot,
StockBasicRow,
SuspendRow,
TradeCalendarRow,
@@ -37,9 +39,11 @@ class FakeRadarSource:
self,
*,
missing_moneyflow: bool = False,
missing_membership: bool = False,
net_scale: Decimal = Decimal(1),
) -> None:
self.missing_moneyflow = missing_moneyflow
self.missing_membership = missing_membership
self.net_scale = net_scale
self.fail_daily = False
self.calls: list[str] = []
@@ -153,6 +157,38 @@ class FakeRadarSource:
self, trade_date: date, sector_codes: Sequence[str]
) -> SourceResult[SectorMemberRow]:
self.calls.append("members")
if self.missing_membership:
snapshots: list[SourceSnapshot] = []
member_rows: list[SectorMemberRow] = []
for sector_code in sector_codes:
sector_rows = (
()
if sector_code == sector_codes[-1]
else tuple(
SectorMemberRow(
trade_date,
sector_code,
f"00000{index}.SZ",
f"股票{index}",
)
for index in range(1, 6)
)
)
snapshots.append(
build_source_snapshot(
api_name="dc_member",
params={
"trade_date": trade_date.isoformat(),
"ts_code": sector_code,
},
rows=tuple(self._raw_row(row) for row in sector_rows),
target_trade_date=trade_date,
partition_key=sector_code,
observed_at=NOW,
)
)
member_rows.extend(sector_rows)
return SourceResult(tuple(snapshots), tuple(member_rows))
rows = tuple(
SectorMemberRow(trade_date, sector_code, f"00000{index}.SZ", f"股票{index}")
for sector_code in sector_codes
@@ -288,6 +324,33 @@ def test_partial_coverage_and_lock_have_distinct_exit_codes() -> None:
assert locked.outcomes[0].status == "locked"
def test_unknown_membership_is_persisted_as_partial_and_retried_independently() -> None:
repository = InMemorySectorRadarRepository()
source = FakeRadarSource(missing_membership=True)
use_case = BuildSectorRadar(source, repository, today=TARGET_DATE, now_fn=lambda: NOW)
partial = use_case.execute(BuildSectorRadarCommand(trade_date=TARGET_DATE))
partial_id = partial.outcomes[0].publication_id
assert partial_id is not None
publication = repository.get_publication(partial_id)
assert partial.status == "partial"
assert publication is not None
assert publication.error_summary == "membership_unknown"
assert any(item.status is MembershipStatus.UNKNOWN for item in repository.memberships.values())
assert {
record.source_group
for record in repository.load_publication_sources(partial_id)
if record.refresh_on_retry
} == {PublicationSourceGroup.MEMBERS}
source.missing_membership = False
source.calls.clear()
retried = use_case.execute(BuildSectorRadarCommand(retry_publication_id=partial_id))
assert retried.status == "success"
assert source.calls == ["members"]
def test_range_builds_dates_in_order_and_retry_uses_old_target() -> None:
repository = InMemorySectorRadarRepository()
source = FakeRadarSource(missing_moneyflow=True)
@@ -1,11 +1,20 @@
from datetime import UTC, date, datetime
from decimal import Decimal
from zhixing_server.modules.sector_radar.domain.models import StockFactStatus
from zhixing_server.modules.sector_radar.domain.normalize import normalize_stock_facts
from zhixing_server.modules.sector_radar.domain.models import (
MembershipStatus,
SectorType,
StockFactStatus,
)
from zhixing_server.modules.sector_radar.domain.normalize import (
normalize_memberships,
normalize_stock_facts,
)
from zhixing_server.modules.sector_radar.domain.source import (
DailyRow,
MoneyflowDcRow,
SectorIndexRow,
SectorMemberRow,
SourceResult,
StockBasicRow,
SuspendRow,
@@ -64,6 +73,69 @@ def moneyflow(ts_code: str, amount: Decimal | None) -> MoneyflowDcRow:
)
def test_membership_normalization_persists_an_explicit_unknown_sector() -> None:
indices = (
SectorIndexRow(
TARGET_DATE,
SectorType.CONCEPT,
"BK0001.DC",
"机器人",
"一级",
Decimal(1),
None,
),
SectorIndexRow(
TARGET_DATE,
SectorType.CONCEPT,
"BK0002.DC",
"低空经济",
"一级",
Decimal(1),
None,
),
)
member = SectorMemberRow(
TARGET_DATE,
"BK0001.DC",
"000001.SZ",
"平安银行",
)
all_snapshot = build_source_snapshot(
api_name="dc_member",
params={"trade_date": "20260828"},
rows=(
{
"trade_date": "20260828",
"ts_code": member.sector_code,
"con_code": member.stock_code,
"name": member.stock_name,
},
),
target_trade_date=TARGET_DATE,
partition_key="all",
observed_at=OBSERVED_AT,
)
empty_partition = build_source_snapshot(
api_name="dc_member",
params={"trade_date": "20260828", "ts_code": "BK0002.DC"},
rows=(),
target_trade_date=TARGET_DATE,
partition_key="BK0002.DC",
observed_at=OBSERVED_AT,
)
records = normalize_memberships(
indices,
SourceResult((all_snapshot, empty_partition), (member,)),
)
assert records[0].status is MembershipStatus.AVAILABLE
assert records[0].stock_code == "000001.SZ"
assert records[1].status is MembershipStatus.UNKNOWN
assert records[1].stock_code is None
assert records[1].membership_key == "__membership_unknown__"
def test_stock_fact_normalization_preserves_all_missing_and_zero_states() -> None:
codes = tuple(f"00000{index}.SZ" for index in range(1, 9))
basics = tuple(
@@ -193,6 +193,37 @@ def test_dc_member_reloads_by_sector_when_the_all_market_call_hits_limit(
]
def test_dc_member_preserves_an_explicit_empty_partition() -> None:
client = QueryClient(
{
(
"dc_member",
"",
): (
{
"trade_date": "20260828",
"ts_code": "BK0001.DC",
"con_code": "000001.SZ",
"name": "A",
},
),
("dc_member", "BK0002.DC"): (),
}
)
result = make_adapter(client).fetch_sector_members(
TARGET_DATE,
("BK0001.DC", "BK0002.DC"),
)
assert [row.sector_code for row in result.rows] == ["BK0001.DC"]
assert [snapshot.partition_key for snapshot in result.snapshots] == [
"all",
"BK0002.DC",
]
assert result.snapshots[1].row_count == 0
def test_stock_basic_explicitly_requests_all_lifecycle_statuses() -> None:
responses = {
(
@@ -240,6 +271,41 @@ def test_source_snapshot_hash_is_order_stable_and_excludes_token_params() -> Non
assert "secret" not in repr(first)
def test_source_snapshot_identity_includes_schema_and_limit_metadata() -> None:
first = build_source_snapshot(
api_name="daily",
params={"trade_date": "20260828"},
rows=({"ts_code": "000001.SZ"},),
target_trade_date=TARGET_DATE,
observed_at=OBSERVED_AT,
returned_fields=("ts_code",),
row_limit=1,
)
changed_schema = build_source_snapshot(
api_name="daily",
params={"trade_date": "20260828"},
rows=({"ts_code": "000001.SZ"},),
target_trade_date=TARGET_DATE,
observed_at=OBSERVED_AT,
returned_fields=("name", "ts_code"),
row_limit=1,
)
changed_limit = build_source_snapshot(
api_name="daily",
params={"trade_date": "20260828"},
rows=({"ts_code": "000001.SZ"},),
target_trade_date=TARGET_DATE,
observed_at=OBSERVED_AT,
returned_fields=("ts_code",),
row_limit=2,
)
assert first.content_sha256 != changed_schema.content_sha256
assert first.snapshot_id != changed_schema.snapshot_id
assert first.content_sha256 != changed_limit.content_sha256
assert first.snapshot_id != changed_limit.snapshot_id
def test_capability_probe_classifies_errors_without_exposing_provider_text() -> None:
client = QueryClient({("daily", ""): RuntimeError("权限不足 private-detail")})