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
@@ -331,9 +331,12 @@ class BuildSectorRadar:
aggregates = self._aggregate(collected)
rankings = self._rank(target.trade_date, aggregates)
coverage = self._coverage(collected.stock_facts)
membership_complete = all(
item.status is MembershipStatus.AVAILABLE for item in collected.memberships
)
terminal = (
PublicationStatus.SUCCESS
if coverage >= self.coverage_threshold
if membership_complete and coverage >= self.coverage_threshold
else PublicationStatus.PARTIAL
)
finished = RadarPublication(
@@ -348,7 +351,13 @@ class BuildSectorRadar:
started_at=started_at,
finished_at=self.now_fn(),
error_summary=(
None if terminal is PublicationStatus.SUCCESS else "coverage_below_threshold"
None
if terminal is PublicationStatus.SUCCESS
else (
"membership_unknown"
if not membership_complete
else "coverage_below_threshold"
)
),
)
self.repository.finalize_publication(
@@ -360,7 +369,10 @@ class BuildSectorRadar:
),
rankings=(RankingRecord(publication_id, ranking) for ranking in rankings),
retry_source_groups=(
self._retry_source_groups(collected.stock_facts)
self._retry_source_groups(
collected.memberships,
collected.stock_facts,
)
if terminal is PublicationStatus.PARTIAL
else ()
),
@@ -529,7 +541,15 @@ class BuildSectorRadar:
)
memberships = normalize_memberships(indices, members)
candidate_codes = tuple(sorted({item.stock_code for item in memberships}))
candidate_codes = tuple(
sorted(
{
item.stock_code
for item in memberships
if item.status is MembershipStatus.AVAILABLE and item.stock_code is not None
}
)
)
stock_facts = normalize_stock_facts(
target_trade_date=target,
candidate_codes=candidate_codes,
@@ -568,10 +588,18 @@ class BuildSectorRadar:
for item in inputs.stock_facts
)
grouped: defaultdict[tuple[SectorType, str, str], list[str]] = defaultdict(list)
unknown: set[tuple[SectorType, str, str]] = set()
for member in inputs.memberships:
grouped[(member.sector_type, member.sector_code, member.sector_name)].append(
member.stock_code
)
key = (member.sector_type, member.sector_code, member.sector_name)
if member.status is MembershipStatus.UNKNOWN:
unknown.add(key)
continue
if member.stock_code is None:
raise ValueError("available membership requires a stock code")
grouped[key].append(member.stock_code)
if unknown & set(grouped):
raise ValueError("sector cannot have both available and unknown membership")
sector_keys = set(grouped) | unknown
aggregates = tuple(
aggregate_sector_snapshot(
SectorMembershipSnapshot(
@@ -579,15 +607,16 @@ class BuildSectorRadar:
sector_type=sector_type,
sector_code=sector_code,
sector_name=sector_name,
member_codes=tuple(sorted(member_codes)),
status=MembershipStatus.AVAILABLE,
member_codes=tuple(sorted(grouped.get(key, ()))),
status=(
MembershipStatus.UNKNOWN if key in unknown else MembershipStatus.AVAILABLE
),
source_version=self._universe_version(inputs.membership_snapshots),
),
facts,
)
for (sector_type, sector_code, sector_name), member_codes in sorted(
grouped.items(), key=lambda item: (str(item[0][0]), item[0][1])
)
for key in sorted(sector_keys, key=lambda item: (str(item[0]), item[1]))
for sector_type, sector_code, sector_name in (key,)
)
if not aggregates:
raise ValueError("sector universe produced no aggregates")
@@ -631,9 +660,12 @@ class BuildSectorRadar:
@staticmethod
def _retry_source_groups(
memberships: Sequence[MembershipRecord],
stock_facts: Sequence[StockFactRecord],
) -> tuple[PublicationSourceGroup, ...]:
groups: list[PublicationSourceGroup] = []
if any(item.status is MembershipStatus.UNKNOWN for item in memberships):
groups.append(PublicationSourceGroup.MEMBERS)
statuses = {item.status for item in stock_facts}
if statuses & {
StockFactStatus.MISSING,
@@ -54,27 +54,53 @@ def normalize_memberships(
),
None,
)
records: list[MembershipRecord] = []
members_by_sector: dict[str, list[SectorMemberRow]] = {code: [] for code in index_by_code}
for member in members.rows:
index = index_by_code.get(member.sector_code)
if index is None:
raise SourceContractError("dc_member references a sector outside dc_index")
snapshot_id = partition_ids.get(member.sector_code, all_snapshot_id)
members_by_sector[member.sector_code].append(member)
records: list[MembershipRecord] = []
for sector_code in sorted(index_by_code):
index = index_by_code[sector_code]
sector_members = members_by_sector[sector_code]
snapshot_id = partition_ids.get(sector_code, all_snapshot_id)
if snapshot_id is None:
raise SourceContractError("membership row has no source snapshot")
records.append(
MembershipRecord(
source_snapshot_id=snapshot_id,
trade_date=member.trade_date,
sector_type=index.sector_type,
sector_code=index.sector_code,
sector_name=index.name,
stock_code=member.stock_code,
stock_name=member.stock_name,
status=MembershipStatus.AVAILABLE,
raise SourceContractError("sector membership has no source snapshot")
if not sector_members:
explicit_partition_id = partition_ids.get(sector_code)
if explicit_partition_id is None:
raise SourceContractError(
"missing sector membership requires an explicit empty partition"
)
records.append(
MembershipRecord(
source_snapshot_id=explicit_partition_id,
trade_date=index.trade_date,
sector_type=index.sector_type,
sector_code=index.sector_code,
sector_name=index.name,
stock_code=None,
stock_name=None,
status=MembershipStatus.UNKNOWN,
)
)
)
return tuple(sorted(records, key=lambda item: (item.sector_code, item.stock_code)))
continue
for member in sector_members:
records.append(
MembershipRecord(
source_snapshot_id=snapshot_id,
trade_date=member.trade_date,
sector_type=index.sector_type,
sector_code=index.sector_code,
sector_name=index.name,
stock_code=member.stock_code,
stock_name=member.stock_name,
status=MembershipStatus.AVAILABLE,
)
)
return tuple(sorted(records, key=lambda item: (item.sector_code, item.membership_key)))
def normalize_stock_facts(
@@ -33,28 +33,36 @@ def _validate_optional_decimal(value: Decimal | None, field_name: str) -> None:
@dataclass(frozen=True, slots=True)
class MembershipRecord:
"""One persisted point-in-time member tied to its raw source revision."""
"""One persisted point-in-time member or explicit unknown snapshot."""
source_snapshot_id: str
trade_date: date
sector_type: SectorType
sector_code: str
sector_name: str
stock_code: str
stock_name: str
stock_code: str | None
stock_name: str | None
status: MembershipStatus = MembershipStatus.AVAILABLE
def __post_init__(self) -> None:
"""Validate revision identity and member fields."""
"""Validate available and unknown membership null semantics."""
_validate_digest(self.source_snapshot_id, "source_snapshot_id")
if self.status is not MembershipStatus.AVAILABLE:
raise ValueError("persisted member rows require available membership")
if any(
not value.strip()
for value in (self.sector_code, self.sector_name, self.stock_code, self.stock_name)
):
raise ValueError("membership identity fields must not be empty")
if not self.sector_code.strip() or not self.sector_name.strip():
raise ValueError("membership sector identity fields must not be empty")
if self.status is MembershipStatus.AVAILABLE:
if self.stock_code is None or self.stock_name is None:
raise ValueError("available membership requires stock identity")
if not self.stock_code.strip() or not self.stock_name.strip():
raise ValueError("available membership stock identity must not be empty")
elif self.stock_code is not None or self.stock_name is not None:
raise ValueError("unknown membership must not expose stock identity")
@property
def membership_key(self) -> str:
"""Return a non-null persistence key without inventing a stock code."""
return self.stock_code if self.stock_code is not None else "__membership_unknown__"
@dataclass(frozen=True, slots=True)
@@ -122,23 +122,6 @@ def build_source_snapshot(
normalized_params = tuple(
sorted((key, str(value)) for key, value in params.items() if key != "token")
)
canonical_rows = sorted(
json.dumps(row, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
for row in normalized_rows
)
content_sha256 = hashlib.sha256("\n".join(canonical_rows).encode()).hexdigest()
identity = json.dumps(
{
"api_name": api_name,
"params": normalized_params,
"partition_key": partition_key,
"content_sha256": content_sha256,
},
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
snapshot_id = hashlib.sha256(identity.encode()).hexdigest()
fields = tuple(
sorted(
set(returned_fields)
@@ -147,6 +130,38 @@ def build_source_snapshot(
)
)
row_count = len(normalized_rows)
limit_reached = row_limit is not None and row_count >= row_limit
canonical_rows = sorted(
json.dumps(row, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
for row in normalized_rows
)
canonical_content = json.dumps(
{
"rows": canonical_rows,
"returned_fields": fields,
"row_limit": row_limit,
"limit_reached": limit_reached,
},
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
content_sha256 = hashlib.sha256(canonical_content.encode()).hexdigest()
identity = json.dumps(
{
"api_name": api_name,
"params": normalized_params,
"partition_key": partition_key,
"target_trade_date": (
target_trade_date.isoformat() if target_trade_date is not None else None
),
"content_sha256": content_sha256,
},
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
snapshot_id = hashlib.sha256(identity.encode()).hexdigest()
return SourceSnapshot(
snapshot_id=snapshot_id,
api_name=api_name,
@@ -159,7 +174,7 @@ def build_source_snapshot(
returned_fields=fields,
content_sha256=content_sha256,
row_limit=row_limit,
limit_reached=row_limit is not None and row_count >= row_limit,
limit_reached=limit_reached,
)
@@ -108,7 +108,11 @@ class InMemorySectorRadarRepository:
return self._insert_immutable(
self.memberships,
records,
key=lambda item: (item.source_snapshot_id, item.sector_code, item.stock_code),
key=lambda item: (
item.source_snapshot_id,
item.sector_code,
item.membership_key,
),
)
def save_stock_facts(self, records: Iterable[StockFactRecord]) -> WriteCounts:
@@ -246,7 +246,11 @@ class PostgresSectorRadarRepository:
items = tuple(records)
self._require_unique(
items,
lambda item: (item.source_snapshot_id, item.sector_code, item.stock_code),
lambda item: (
item.source_snapshot_id,
item.sector_code,
item.membership_key,
),
)
rows = tuple(
(
@@ -255,6 +259,7 @@ class PostgresSectorRadarRepository:
item.sector_type.value,
item.sector_code,
item.sector_name,
item.membership_key,
item.stock_code,
item.stock_name,
item.status.value,
@@ -269,11 +274,12 @@ class PostgresSectorRadarRepository:
"sector_type",
"sector_code",
"sector_name",
"membership_key",
"stock_code",
"stock_name",
"membership_status",
),
("source_snapshot_id", "sector_code", "stock_code"),
("source_snapshot_id", "sector_code", "membership_key"),
rows,
)
@@ -412,7 +418,11 @@ class PostgresSectorRadarRepository:
ranking_items = tuple(rankings)
self._require_unique(
membership_items,
lambda item: (item.source_snapshot_id, item.sector_code, item.stock_code),
lambda item: (
item.source_snapshot_id,
item.sector_code,
item.membership_key,
),
)
self._require_unique(stock_items, lambda item: (item.fact_revision, item.ts_code))
self._require_unique(
@@ -442,11 +452,12 @@ class PostgresSectorRadarRepository:
"sector_type",
"sector_code",
"sector_name",
"membership_key",
"stock_code",
"stock_name",
"membership_status",
),
("source_snapshot_id", "sector_code", "stock_code"),
("source_snapshot_id", "sector_code", "membership_key"),
tuple(
(
item.source_snapshot_id,
@@ -454,6 +465,7 @@ class PostgresSectorRadarRepository:
item.sector_type.value,
item.sector_code,
item.sector_name,
item.membership_key,
item.stock_code,
item.stock_name,
item.status.value,
@@ -229,8 +229,6 @@ class TushareSectorRadarAdapter:
self._require_target_date(partition_rows, trade_date, "dc_member")
if any(row.sector_code != sector_code for row in partition_rows):
raise SourceContractError("dc_member partition returned a different sector")
if not partition_rows:
raise SourceContractError("dc_member cannot prove complete membership for a sector")
snapshots.append(snapshot)
merged_rows.extend(partition_rows)
@@ -240,7 +238,12 @@ class TushareSectorRadarAdapter:
api_name="dc_member",
)
final_codes = {row.sector_code for row in merged_rows}
if set(expected_codes) - final_codes:
explicitly_observed_codes = {
snapshot.partition_key
for snapshot in snapshots
if snapshot.partition_key not in {None, "all"}
}
if set(expected_codes) - final_codes - explicitly_observed_codes:
raise SourceContractError("dc_member response is missing expected sectors")
return SourceResult(
tuple(snapshots),
@@ -94,7 +94,7 @@ class RadarRankingRowResponse(BaseModel):
membership_coverage: Decimal = Field(ge=0, le=1)
moneyflow_coverage: Decimal = Field(ge=0, le=1)
rank_position: int | None = Field(default=None, ge=1)
rank_percentile: Decimal | None = Field(default=None, ge=0, le=100)
rank_percentile: Decimal | None = Field(default=None, gt=0, le=100)
rank_change_days: int = Field(ge=1, le=5)
rank_change: int | None