fix(sector-radar): 收紧可复现发布契约
This commit is contained in:
@@ -57,11 +57,13 @@
|
||||
|
||||
## 6. 全量验证与审查
|
||||
|
||||
- [ ] 后端:`uv run ruff format --check .`、`uv run ruff check .`、`uv run pyright`、`uv run pytest`。
|
||||
- [ ] 前端:`pnpm format:check`、`pnpm lint`、`pnpm typecheck`、`pnpm test`、`pnpm build`。
|
||||
- [ ] 根级:`./dev.sh check`、`./dev.sh test`;验证开发和生产 Compose config。
|
||||
- [ ] 使用 `trellis-check` 做全范围规范、PRD、跨层字段、单位、空值、版本声明和凭据泄漏检查,并修复发现项。
|
||||
- [ ] 评估是否有经用户批准才应提升到 `.trellis/spec/` 的新知识;未经批准不写 Trellis spec。
|
||||
- [x] 后端:`uv run ruff format --check .`、`uv run ruff check .`、`uv run pyright`、`uv run pytest`。
|
||||
- [x] 前端:`pnpm format:check`、`pnpm lint`、`pnpm typecheck`、`pnpm test`、`pnpm build`。
|
||||
- [x] 根级:`./dev.sh check`、`./dev.sh test`;验证开发和生产 Compose config。
|
||||
- [x] 使用 `trellis-check` 做全范围规范、PRD、跨层字段、单位、空值、版本声明和凭据泄漏检查,并修复发现项。
|
||||
- [x] 评估是否有经用户批准才应提升到 `.trellis/spec/` 的新知识;未经批准不写 Trellis spec。
|
||||
|
||||
阶段结果(2026-08-29):全范围终审补齐三项契约:`SourceSnapshot` identity 绑定返回字段、行上限和截断状态;显式空 `dc_member` 分区持久化为 `membership_unknown`,生成 unavailable 聚合并强制 publication 为 partial,只重试成员来源且绝不替换 last-good;HTTP 与前端把排名百分位统一收紧为 `(0, 100]`。新增迁移 head `0006_membership_unknown`,离线升级 SQL 已核对。最终后端 Ruff、Pyright 和全量测试为 139 passed、3 skipped,跳过项均需要 `ZHIXING_TEST_DATABASE_URL`;前端 format、lint、typecheck、全量 64 项 Vitest 与 build 通过,build 仅有既有单包大于 500 kB 的非阻塞提示;`./dev.sh check`、`./dev.sh test` 和开发/生产、默认/jobs 四种 Compose `config --quiet` 均通过。Compose 验证显式清空 `ZHIXING_TUSHARE_TOKEN` 并使用无敏感信息的占位数据库 URL。真实 PostgreSQL 集成、真实 Tushare capability、生产网络和部署权限仍未在本机环境验证,不将其误报为通过。全范围只读复核最终为 no blocking findings。经 `trellis-update-spec` 评估,unknown-membership 与快照 identity 属于可提升的候选知识,但用户未批准写 `.trellis/spec/`,本任务仅在设计、测试和本执行记录中保存。
|
||||
|
||||
## 风险与回滚点
|
||||
|
||||
|
||||
@@ -55,14 +55,14 @@ Tushare token 只从 `Settings`/环境注入,不得写入源码、日志、响
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] AC1:给定固定样本和乱序输入,概念/行业的三套排名、排名百分位、普通前后榜和 1—5 日排名变化结果可重复,且测试覆盖空池、单元素池、并列值、历史缺失和非有限数。
|
||||
- [ ] AC2:每条雷达结果可追溯到唯一发布版本、来源版本、universe 版本和指标策略版本;响应和页面明确标注“知行独立实现”,不暴露或暗示原站 `Ratio_Score`、`Swing_Score` 字段。
|
||||
- [ ] AC3:缺失资金流、成员未知、低流动性、部分覆盖与失败发布不会被展示成完整的零值结果;失败构建不覆盖 `last_good`。
|
||||
- [ ] AC4:后端 HTTP 契约测试锁定筛选、分页/榜单、数据状态和错误行为;前端类型、API adapter、query 与页面测试覆盖 loading/error/no-data/stale/partial/success。
|
||||
- [ ] AC5:页面可分别浏览概念与行业排名池,并按交易日、指标视角和强弱榜筛选;金额、比例、策略版本与排名变化的单位和方向符合本任务契约。
|
||||
- [ ] AC6:收盘后 Job 可幂等重复执行,重复内容不产生无意义修订;同一日期并发执行被锁阻止,失败时保留最近有效发布。
|
||||
- [ ] AC7:运行相关后端 Ruff、Pyright、pytest 与前端 format、lint、typecheck、Vitest、build;跨层链路通过根级检查。没有实际运行的检查不得标记为通过。
|
||||
- [ ] AC8:生产运行时不请求 OneChartLab;Tushare 原始响应、point-in-time 成员和规范化股票事实足以重放同一指标策略版本,且公开样本只用于对账,不覆盖本地事实。
|
||||
- [x] AC1:给定固定样本和乱序输入,概念/行业的三套排名、排名百分位、普通前后榜和 1—5 日排名变化结果可重复,且测试覆盖空池、单元素池、并列值、历史缺失和非有限数。
|
||||
- [x] AC2:每条雷达结果可追溯到唯一发布版本、来源版本、universe 版本和指标策略版本;响应和页面明确标注“知行独立实现”,不暴露或暗示原站 `Ratio_Score`、`Swing_Score` 字段。
|
||||
- [x] AC3:缺失资金流、成员未知、低流动性、部分覆盖与失败发布不会被展示成完整的零值结果;失败构建不覆盖 `last_good`。
|
||||
- [x] AC4:后端 HTTP 契约测试锁定筛选、分页/榜单、数据状态和错误行为;前端类型、API adapter、query 与页面测试覆盖 loading/error/no-data/stale/partial/success。
|
||||
- [x] AC5:页面可分别浏览概念与行业排名池,并按交易日、指标视角和强弱榜筛选;金额、比例、策略版本与排名变化的单位和方向符合本任务契约。
|
||||
- [x] AC6:收盘后 Job 可幂等重复执行,重复内容不产生无意义修订;同一日期并发执行被锁阻止,失败时保留最近有效发布。
|
||||
- [x] AC7:运行相关后端 Ruff、Pyright、pytest 与前端 format、lint、typecheck、Vitest、build;跨层链路通过根级检查。没有实际运行的检查不得标记为通过。
|
||||
- [x] AC8:生产运行时不请求 OneChartLab;Tushare 原始响应、point-in-time 成员和规范化股票事实足以重放同一指标策略版本,且公开样本只用于对账,不覆盖本地事实。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Persist explicit unknown point-in-time sector membership snapshots."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0006_membership_unknown"
|
||||
down_revision: str | None = "0005_radar_daily_aggregate"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Allow one null-stock marker for an explicitly empty sector partition."""
|
||||
|
||||
op.add_column(
|
||||
"sector_radar_membership",
|
||||
sa.Column("membership_key", sa.String(32), nullable=True),
|
||||
)
|
||||
op.execute("UPDATE sector_radar_membership SET membership_key = stock_code")
|
||||
op.drop_constraint(
|
||||
"sector_radar_membership_pkey",
|
||||
"sector_radar_membership",
|
||||
type_="primary",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_sector_radar_membership_status",
|
||||
"sector_radar_membership",
|
||||
type_="check",
|
||||
)
|
||||
op.alter_column(
|
||||
"sector_radar_membership",
|
||||
"membership_key",
|
||||
existing_type=sa.String(32),
|
||||
nullable=False,
|
||||
)
|
||||
op.alter_column(
|
||||
"sector_radar_membership",
|
||||
"stock_code",
|
||||
existing_type=sa.String(12),
|
||||
nullable=True,
|
||||
)
|
||||
op.alter_column(
|
||||
"sector_radar_membership",
|
||||
"stock_name",
|
||||
existing_type=sa.String(128),
|
||||
nullable=True,
|
||||
)
|
||||
op.create_primary_key(
|
||||
"sector_radar_membership_pkey",
|
||||
"sector_radar_membership",
|
||||
["source_snapshot_id", "sector_code", "membership_key"],
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_sector_radar_membership_status",
|
||||
"sector_radar_membership",
|
||||
"(membership_status = 'available' "
|
||||
"AND stock_code IS NOT NULL AND stock_name IS NOT NULL "
|
||||
"AND membership_key = stock_code) OR "
|
||||
"(membership_status = 'membership_unknown' "
|
||||
"AND stock_code IS NULL AND stock_name IS NULL "
|
||||
"AND membership_key = '__membership_unknown__')",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Discard unknown markers and restore the available-member-only schema."""
|
||||
|
||||
op.execute("DELETE FROM sector_radar_membership WHERE membership_status = 'membership_unknown'")
|
||||
op.drop_constraint(
|
||||
"ck_sector_radar_membership_status",
|
||||
"sector_radar_membership",
|
||||
type_="check",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"sector_radar_membership_pkey",
|
||||
"sector_radar_membership",
|
||||
type_="primary",
|
||||
)
|
||||
op.alter_column(
|
||||
"sector_radar_membership",
|
||||
"stock_code",
|
||||
existing_type=sa.String(12),
|
||||
nullable=False,
|
||||
)
|
||||
op.alter_column(
|
||||
"sector_radar_membership",
|
||||
"stock_name",
|
||||
existing_type=sa.String(128),
|
||||
nullable=False,
|
||||
)
|
||||
op.drop_column("sector_radar_membership", "membership_key")
|
||||
op.create_primary_key(
|
||||
"sector_radar_membership_pkey",
|
||||
"sector_radar_membership",
|
||||
["source_snapshot_id", "sector_code", "stock_code"],
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_sector_radar_membership_status",
|
||||
"sector_radar_membership",
|
||||
"membership_status = 'available'",
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")})
|
||||
|
||||
|
||||
@@ -160,4 +160,23 @@ describe("sector radar API adapters", () => {
|
||||
}),
|
||||
).rejects.toThrow("rankings.rows[0].metric_value must be finite")
|
||||
})
|
||||
|
||||
it("rejects a zero rank percentile that the domain cannot produce", async () => {
|
||||
requestJson.mockResolvedValue({
|
||||
...rankingPayload,
|
||||
rows: [{ ...rankingPayload.rows[0], rank_percentile: "0" }],
|
||||
})
|
||||
|
||||
await expect(
|
||||
getSectorRadarRankings({
|
||||
sectorType: "concept",
|
||||
view: "amount",
|
||||
rankChangeMetric: "amount",
|
||||
rankChangeDays: 1,
|
||||
side: "all",
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
}),
|
||||
).rejects.toThrow("rankings.rows[0].rank_percentile must be greater than 0")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -270,9 +270,8 @@ function readRankingRow(value: unknown, index: number): RadarRankingRow {
|
||||
1,
|
||||
`${path}.rank_position`,
|
||||
),
|
||||
rank_percentile: readNullableNumberInRange(
|
||||
rank_percentile: readNullablePositiveNumber(
|
||||
record.rank_percentile,
|
||||
0,
|
||||
100,
|
||||
`${path}.rank_percentile`,
|
||||
),
|
||||
@@ -403,16 +402,15 @@ function readNullableInteger(
|
||||
return readIntegerInRange(value, min, Number.MAX_SAFE_INTEGER, path)
|
||||
}
|
||||
|
||||
function readNullableNumberInRange(
|
||||
function readNullablePositiveNumber(
|
||||
value: unknown,
|
||||
min: number,
|
||||
max: number,
|
||||
path: string,
|
||||
): number | null {
|
||||
if (value === null) return null
|
||||
const number = readFiniteNumber(value, path)
|
||||
if (number < min || number > max) {
|
||||
throw contractError(path, `must be between ${min} and ${max}`)
|
||||
if (number <= 0 || number > max) {
|
||||
throw contractError(path, `must be greater than 0 and at most ${max}`)
|
||||
}
|
||||
return number
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user