Develop #15

Merged
sakibcc merged 11 commits from develop into main 2026-08-29 22:50:23 +08:00
68 changed files with 10836 additions and 217 deletions
+5
View File
@@ -23,6 +23,11 @@ ZHIXING_MARKET_DATA_MAX_RETRIES=3
ZHIXING_MARKET_DATA_RETRY_BACKOFF_SECONDS=1.0
ZHIXING_MARKET_DATA_REQUEST_INTERVAL_SECONDS=0.2
ZHIXING_MARKET_DATA_ADVISORY_LOCK_KEY=7380521
ZHIXING_SECTOR_RADAR_COVERAGE_THRESHOLD=0.99
ZHIXING_SECTOR_RADAR_MAX_RETRIES=3
ZHIXING_SECTOR_RADAR_RETRY_BACKOFF_SECONDS=1.0
ZHIXING_SECTOR_RADAR_REQUEST_INTERVAL_SECONDS=0.2
ZHIXING_SECTOR_RADAR_ADVISORY_LOCK_KEY=7380522
ZHIXING_SELECTION_MAX_WORKERS=4
ZHIXING_SELECTION_BATCH_SIZE=200
API_UPSTREAM=http://server:8000
@@ -0,0 +1,8 @@
{"file": ".trellis/spec/backend/index.md", "reason": "检查后端模块边界与开发清单"}
{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "检查 Ruff、Pyright、pytest 与禁止模式"}
{"file": ".trellis/spec/frontend/index.md", "reason": "检查前端 feature 与质量清单"}
{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "检查格式、lint、类型、测试、构建和可访问性"}
{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "检查后端响应、前端类型、query 与页面一致性"}
{"file": ".trellis/tasks/08-28-sector-capital-radar/research/implementation-evidence.md", "reason": "检查独立指标声明、单位、空值和凭据边界"}
{"file": "docs/research/onechartlab-sector-capital-radar.md", "reason": "禁止把未知公式伪装为原站公式"}
{"file": ".trellis/tasks/08-28-sector-capital-radar/research/tushare-radar-contract.md", "reason": "检查接口、PIT、单位、覆盖率和 last-good 质量语义"}
@@ -0,0 +1,118 @@
# 板块资金雷达技术设计
## 设计结论
首个 MVP 建设 Tushare 独立生产链,生产运行时不读取 OneChartLab。后端新增 `sector_radar` bounded context,通过一次性 CLI 采集七类最小事实、保存 point-in-time 输入、计算三个带版本的知行独立指标、生成横截面排名并发布;FastAPI 只读取已发布结果。前端新增 `/sector-radar` feature,提供日期、板块类型、指标、强弱榜、排名变化、搜索和分页。
首个安全里程碑不实现成分股详情、历史轨迹图、导出、`moneyflow` 主买净额、`daily_basic` 流动性增强或 `moneyflow_ind_dc` 对账。架构为这些能力保留数据版本与指标策略 seam,但不创建空实现。
## Module 与 seam
### `sector_radar` bounded context
外部 interface 保持两个深模块:
1. `BuildSectorRadar.execute(command) -> BuildSummary` 隐藏目标交易日解析、采集、校验、持久化、指标计算、排名和发布切换。
2. `ReadSectorRadar.list_dates()` 与 `ReadSectorRadar.query(query) -> RankingPage` 隐藏 last-good 选择、筛选、排序和分页。
内部保留三个真实 seam:
- `SectorRadarSource`:Tushare 生产 adapter 与测试 fake adapter;负责 `trade_cal`、`dc_index`、`dc_member`、`stock_basic`、`suspend_d`、`daily`、`moneyflow_dc`。
- `SectorRadarRepository`:PostgreSQL 生产 adapter 与 application 测试 fake adapter;负责输入版本、发布批次和读取投影。
- `MetricStrategy`:金额、单日资金率和波段资金率是三个实际可替换 adapter;每个结果必须带 `metric_version`、显示标签、排序值、单位和质量状态。
Tushare 请求协调能力已有第二个真实消费者后,将 `RequestCoordinator` 从 `market_data` infrastructure 提升为无业务所有权的小型 shared 基础能力;原市场同步与雷达 adapter 同时复用,且保留现有行为测试。
## 领域模型
- `SectorType`:`concept` 或 `industry`;地域板块不进入 MVP。
- `SectorMembershipSnapshot`:`trade_date + sector_code + stock_code`,只表示该交易日的成员事实。
- `StockDailyFact`:股票在交易日的生命周期、停牌、成交额和主力净额状态;缺行、NULL、0 和无效成员不同义。
- `MetricObservation`:策略版本生成的值、单位、有效样本数、成员覆盖率和质量状态。
- `RadarPublication`:针对一个目标交易日的不可变构建版本,状态为 `running|success|partial|failed`,包含输入 hash、universe 版本、指标版本集合、覆盖率、开始/完成时间和安全错误摘要。
- `RadarRanking`:属于某个 publication 和排名池的指标值、1 基排名、排名百分位及 1—5 日变化。
- `LastGoodPublication`:不使用可被失败构建覆盖的可变字段;读取时选择最近一个 `status=success` 的 publication。失败与 partial 批次仍保留审计。
## 数据采集与质量
每日 Job 的目标交易日由 `trade_cal` 确认。目标日采集 `dc_index` 的概念与行业、`dc_member`、全部上市状态的 `stock_basic`、`suspend_d`、`daily` 和 `moneyflow_dc`。首次初始化或补算按交易日顺序执行,至少准备 10 个交易日才能产生完整波段指标,至少保留 30 个已发布交易日供排名变化和后续轨迹使用。
原始响应按 `api_name + normalized_params + observed_at` 保存 JSONB、行数和 SHA-256;规范表保存 point-in-time 成员与股票事实。响应接近官方单次上限或覆盖率不足时必须分片重拉,不能接受可能截断的成功响应。
有效股票候选满足目标日生命周期并属于沪深 A 股,排除北交所与 B 股;不沿用选股模块的 ST 排除规则。停牌且无 `daily` 的股票标记为 suspended,不作为应有行情缺失;应有 `daily` 或 `moneyflow_dc` 却缺失的股票进入覆盖率缺口,不能补 0。
发布成功门使用可配置的全局事实覆盖率,默认沿用项目的 `0.99`。成员快照必须完整;任一必需接口硬失败、重复业务键、日期错误、非有限数、单位校验失败或覆盖率低于门槛,当前 publication 为 `partial` 或 `failed`,不得成为 last-good。每个板块仍输出有效样本数、成员覆盖率和质量状态;少于 5 个有效成员标记 `available_limited_sample`。
## 独立指标策略
对板块 `s`、交易日 `t`,使用当日 point-in-time 有效成员:
```text
Net(s,t) = sum(moneyflow_dc.net_amount) × 10_000 元
Turnover(s,t) = sum(daily.amount) × 1_000 元
```
- `zhixing_amount_net_bn_v1 = Net(s,t) / 100_000_000`,排序值为亿元净额。
- `zhixing_ratio_turnover_v1 = Net(s,t) / Turnover(s,t)`;分母为 0 或输入不完整时为 NULL。
- 对每个 `w ∈ [3,10]`,`WindowRatio_w = sum(Net(s,d)) / sum(Turnover(s,d))`,其中每个 `d` 使用自己的成员快照;`zhixing_swing_equal_3_10_v1` 是八个完整 `WindowRatio_w` 的算术平均。任一窗口不完整时为 NULL。
这些名称和页面标签都明确写“知行独立实现”。接口不暴露原站的 `Ratio_Score` 或 `Swing_Score` 字段名,而统一返回 `metric_value`、`metric_version`、`unit` 和 `implementation_kind=independent`。
## 排名契约
概念与行业分别成池。NULL 指标不进入排名,但作为 unavailable 记录保留质量信息。非 NULL 值按 `metric_value DESC, sector_code ASC` 排序,后者是知行独立稳定键,不宣称原站并列规则。
```text
RankPct = 100 * (N - RankPos + 1) / N
Top = RankPct >= 90
Bottom = RankPct <= 10
RankChg = PastRank - CurrentRank
```
排名变化按过去第 1—5 个已发布交易日计算;过去或当前排名缺失时返回 NULL,而不是伪造 0。前端明确显示暂无可比历史。
## 持久化
新增一条 Alembic 迁移,至少包含:
- `sector_radar_source_snapshot`:接口、参数、目标日、原始 JSONB、行数、hash、观测时间;
- `sector_radar_membership`:来源快照、交易日、板块类型、板块代码、股票代码及展示名;
- `sector_radar_stock_fact`:来源快照集合、交易日、股票代码、生命周期/停牌状态、成交额、主力净额和数据状态;
- `sector_radar_publication`:构建身份、状态、版本、覆盖率、input hash、时间和错误摘要;
- `sector_radar_ranking`:publication、板块身份、指标版本、数值、单位、质量、排名百分位和历史变化。
业务唯一键必须包含交易日和来源/发布版本,允许同一交易日修订共存。数值使用有限 `NUMERIC`/`Decimal`;批量写入沿用 staging + COPY + 幂等 upsert。原始 token、完整请求头和未经净化的异常不得持久化。
## CLI 与发布流程
新增 `sector-radar-build` 一次性 CLI:
- 默认构建最近一个已收盘交易日;
- `--trade-date YYYY-MM-DD` 构建单日;
- `--start-date/--end-date` 按交易日顺序初始化或回填;
- `--retry-publication-id` 只重试失败来源分片。
Job 使用独立 advisory lock。顺序为准备 running publication、采集并保存原始版本、规范化与质量屏障、计算指标与排名、事务写入、标记 success。任何阶段失败都保留原 publication 审计,读取端继续选择 last-good。FastAPI 不启动定时器;生产 Compose 增加 job service,实际定时继续由外部调度器负责。
## HTTP 契约
挂载前缀 `/api/v1/sector-radar`:
- `GET /dates`:返回可用成功日期、当前尝试状态、last-good 日期和发布时间;
- `GET /rankings`:参数为 `trade_date`(缺省 last-good)、`sector_type`、`view=amount|ratio|swing|rank_change`、`rank_change_metric`、`rank_change_days=1..5`、`side=top|bottom|all`、`search`、`page` 和 `page_size`。
响应包含 publication 元数据、指标定义与独立实现声明、分页信息和排名行。没有成功发布时返回稳定的 `no_data` 成功响应;非法参数由 Pydantic/FastAPI 返回 422;数据库不可用映射 503。外部源异常只发生在 Job,不从读取端点实时透传。
## 前端
新增 `features/sector-radar` 垂直切片以及 `/sector-radar` 路由和导航入口。URL 保存日期、类型、视角、榜侧、搜索和分页;服务器数据只进入 React Query。页面使用现有 `PageLayout`、`Card`、`Input`、`Select`、`Badge`、`Pagination` 和语义 table,显式显示 loading、error、no-data、stale/partial/success。
首个 MVP 使用排名表和状态摘要,不引入图表库。金额显示亿元,比率显示百分比,指标旁始终显示策略版本或“知行独立实现”。
## 兼容、回滚与风险
- 新表、新路由和新 feature 不改变现有 market-data/selection 契约;shared 请求协调器移动必须先保持现有测试通过。
- 数据库迁移 downgrade 只删除新上下文表,不触碰现有市场数据。
- 外部调度在 job 验证稳定前保持关闭;手工构建与读取验证通过后再启用。
- 主要风险是目标账号实际权限/限流、`dc_member` 分片完整性和数据到达时间。首次实现必须提供不泄密的 capability probe 与覆盖率报告;没有 live token 时以 fake/golden 完成自动化验证,但不得声称生产采集通过。
- OneChartLab 对账差异只记录为研究数据,不自动覆盖本地结果。
@@ -0,0 +1,8 @@
{"file": ".trellis/spec/backend/index.md", "reason": "后端 bounded context、HTTP、市场同步与质量规范入口"}
{"file": ".trellis/spec/backend/market-data-sync.md", "reason": "复用 Tushare、批次、幂等、单位和发布约束"}
{"file": ".trellis/spec/backend/http-api-contracts.md", "reason": "新增 sector radar 同源 HTTP 契约"}
{"file": ".trellis/spec/frontend/index.md", "reason": "前端 feature、类型、query 与页面规范入口"}
{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "保持 Pydantic 到前端页面的跨层字段一致"}
{"file": ".trellis/tasks/08-28-sector-capital-radar/research/implementation-evidence.md", "reason": "实现范围、仓库复用点、Tushare 客户端和独立指标契约"}
{"file": "docs/research/onechartlab-sector-capital-radar.md", "reason": "公开确认排名算法与未知公式边界"}
{"file": ".trellis/tasks/08-28-sector-capital-radar/research/tushare-radar-contract.md", "reason": "Radar MVP 所需 Tushare 接口、字段、单位、PIT 与质量规则"}
@@ -0,0 +1,73 @@
# 板块资金雷达执行计划
## 开始前门禁
- [ ] 用户审阅并明确批准 `prd.md`、`design.md` 和本计划后,运行 `task.py start`。
- [ ] 从 `develop@ad9545e` 创建/切换 `codex/sector-capital-radar`,写入任务 branch/base-branch 元数据;保留旧调研任务不变。
- [ ] 确认 `implement.jsonl` 与 `check.jsonl` 均含真实 spec/research 条目。
## 1. 纯领域安全里程碑
- [x] 在新的 `sector_radar` bounded context 定义板块类型、成员快照、股票事实、指标观察、发布与排名模型。
- [x] 先写固定人工样本测试,再实现 `zhixing_amount_net_bn_v1`、`zhixing_ratio_turnover_v1`、`zhixing_swing_equal_3_10_v1`。
- [x] 实现概念/行业分池、稳定并列键、1 基排名、百分位、TOP/BOTTOM 和 1—5 日排名变化。
- [x] 覆盖乱序输入、NULL/0、非有限数、空池、单元素、并列、历史缺失、停牌和 point-in-time 成员变化。
- [x] 运行 `uv run --directory zhixing-server pytest tests/unit/sector_radar`、Ruff 与 Pyright。此步绿灯是第一个可回滚安全点。
阶段结果(2026-08-29):三个透明指标策略、point-in-time 事实聚合、发布生命周期和横截面排名 seam 均已实现;13 个板块雷达领域测试通过。完整后端门禁为 96 passed、2 skipped,两个跳过项均为需要 `ZHIXING_TEST_DATABASE_URL` 的既有 PostgreSQL 集成测试。
## 2. Tushare 输入与持久化
- [x] 把已有 RequestCoordinator 提升到 shared 基础设施,保持 market-data 适配器及测试行为不变。
- [x] 定义 `SectorRadarSource` 与 Tushare adapter,显式请求七类接口及 fields;token 仅由 `Settings` 注入。
- [x] 实现服务端错误分类、有限重试、行数上限检测、`dc_member` 分片和账号 capability probe;输出不得包含 token。
- [x] 新增 Alembic 表、约束、索引和 downgrade,保存原始 JSONB/hash、成员快照、股票事实、publication 与 ranking。
- [x] 实现 PostgreSQL staging/COPY、幂等重跑、同日多修订、advisory lock 和 last-good 查询。
- [x] 为 repository fake、Tushare fake、迁移和 PostgreSQL 集成补测试;仅在 `ZHIXING_TEST_DATABASE_URL` 存在时执行数据库集成测试。
- [x] 若运行环境存在 `ZHIXING_TUSHARE_TOKEN`,执行只读 capability probe 并记录接口成功、字段和行数,不打印原始凭据;否则明确记录 live 验证未执行。
阶段结果(2026-08-29):七接口 source 契约、共享限流协调、源快照 hash、point-in-time 规范化、五张 PostgreSQL 表、COPY staging、同日修订与严格 `success` last-good 已落地。完整后端门禁为 109 passed、3 skipped;当前环境未设置 `ZHIXING_TUSHARE_TOKEN` 和 `ZHIXING_TEST_DATABASE_URL`,因此 live capability probe 与三项 PostgreSQL 集成测试未执行,未将其误报为通过。
## 3. 构建 Job
- [x] 实现 `BuildSectorRadar.execute` 的单日与日期区间编排、质量屏障、publication 状态和失败保留 last-good。
- [x] 新增 `sector-radar-build` CLI 及退出码;支持目标日、回填区间和失败 publication 重试。
- [x] 增加 Compose job service,但不启用生产定时;更新运行文档与无凭据示例。
- [x] 用 fake/golden 验证完整成功、部分数据、截断响应、重复运行、输入修订、并发锁和失败降级。
阶段结果(2026-08-29):单日/区间构建、上海时区最近已收盘日、provisional publication、同日锁、遗留 running 接管、内容 hash 去重、严格 last-good 与来源组检查点均已落地。failed 重试只补未完成来源组,partial 只刷新显式覆盖缺口;规范事实、日聚合、排名与 terminal publication 由 PostgreSQL 单事务完成。CLI、开发/生产 Compose entrypoint 和运行文档已提供。完整后端门禁为 122 passed、3 skipped;迁移头与离线升级 SQL、四种 Compose config 和 CLI help 已通过。当前未设置 `ZHIXING_TEST_DATABASE_URL`,三项真实 PostgreSQL 集成测试未执行;真实 Tushare capability 与数据到达时点也未在本阶段宣称通过。
## 4. HTTP 读取链
- [x] 实现 `ReadSectorRadar` 查询模块以及 `/dates`、`/rankings` Pydantic 契约。
- [x] 在路由目录挂载 `/api/v1/sector-radar`;实现筛选、分页、搜索、rank-change 参数和 `no_data`/503 行为。
- [x] 使用真实 `create_app()` 与 fake application dependency 写黑盒 HTTP 契约测试。
阶段结果(2026-08-29):读取端严格区分最新尝试、指定日期成功修订和全局 last-good;概念/行业分池支持 amount、ratio、swing、rank_change、普通百分位强弱榜、排名变化强弱榜、搜索与分页。响应携带 publication/source/universe/metric 版本、单位、质量与“知行独立实现”声明;无成功发布稳定返回 200 `no_data`,参数错误返回 422,存储错误返回脱敏 503。完整后端门禁为 134 passed、3 skipped;跳过项仍为需要 `ZHIXING_TEST_DATABASE_URL` 的真实 PostgreSQL 集成测试。
## 5. 前端 MVP
- [x] 新建 feature API types、adapter 与 React Query hooks;API 边界校验稳定枚举和关键字段。
- [x] 新增 `/sector-radar` 路由、导航、URL search 校验和活动路由映射。
- [x] 实现状态摘要、筛选工具栏、排名表和分页;显示单位、质量状态、数据日期、last-good/stale 和独立指标版本。
- [x] 页面测试覆盖成功、筛选、rank-change、loading、error、no-data、stale/partial;adapter 测试覆盖 URL、参数和 AbortSignal。
- [x] 不引入图表依赖,不实现成分详情、历史轨迹或导出。
阶段结果(2026-08-29):新增独立 `features/sector-radar` API、运行时契约解析、React Query hooks、URL search 驱动的筛选与分页、桌面/移动排名表、发布来源与质量摘要,以及 `/sector-radar` 导航入口。页面明确区分初次加载、致命错误、无数据、后台刷新、后台刷新失败、partial/failed/running 新尝试和 success,并始终保留“知行独立实现”与版本声明;排名变化缺少历史时显示“暂无可比历史”。前端 lint、typecheck、全量 63 项 Vitest 和生产 build 通过,变更文件的 Prettier 检查通过;完整 `pnpm format:check` 仍被未修改的既有 `zhixing-web/DESIGN.md` 格式问题阻挡。浏览器已在默认桌面视口与 390×844 移动视口验证导航、筛选布局、错误降级和 rank-change URL 状态;本地后端未运行,因此成功数据态的视觉行为由页面测试覆盖,未声称真实数据库页面已验证。
## 6. 全量验证与审查
- [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/`,本任务仅在设计、测试和本执行记录中保存。
## 风险与回滚点
- RequestCoordinator 提升后若现有 market-data 检查失败,先还原该重构,雷达 adapter 暂时内部组合相同行为,不改变现有同步。
- 数据库迁移与 Job 在 HTTP/前端之前独立落地;迁移失败可 downgrade 新表,不能修改现有市场数据表。
- live Tushare 调用只用于只读能力与数据质量验证;权限或到达时间不满足时,保留 fake/golden 里程碑并报告阻塞,不降低质量门或把缺失补 0。
- 前端只读取 success/last-good;后端发布未稳定前不启用外部定时任务。
@@ -0,0 +1,74 @@
# 板块资金雷达模块
## Goal
在知行系统中提供一个收盘后可查询的板块资金雷达,使用户能够按交易日分别查看概念板块与行业板块的资金强弱、前后榜和排名变化,并能辨认数据新鲜度、覆盖率与指标来源。实现以公开证据可复现为首要目标;任何未公开公式都必须采用有独立名称、版本和说明的可替换策略,不能宣称为 OneChartLab 原站公式。
## Background
- OneChartLab 当前产品是收盘后的板块横截面排名系统,不是盘中实时雷达。公开证据已确认概念与行业为两个独立排名池,排名键分别为 `Swing_Score`、`Ratio_Score` 与 `Amount_Raw_BN`,排名百分位、前后 10% 榜以及 1—5 日排名变化算法可确定性复现。依据:`docs/research/onechartlab-sector-capital-radar.md`。
- `Ratio_Score`、`Amount_Score`、3—10 日权重、`Swing_Score` 及相关异常值处理没有公开公式。独立实现不得根据字段名、线性拟合或作者口述伪造等价公式。依据:`docs/research/onechartlab-sector-capital-radar.md`、`docs/research/onechartlab-tushare-data-requirements.md`。
- 公开消费层与独立生产层是两个不同目标。前者可原样保存 OneChartLab manifest、日期分片、排名历史和成分详情以复现当前页面;后者需要 Tushare 的 point-in-time 板块成员和资金事实,并只能先生成明确标注的替代指标。依据:`docs/research/onechartlab-tushare-data-requirements.md`。
- 当前仓库已有 FastAPI 模块化单体、PostgreSQL 批次审计、Tushare 请求协调、原子 CSV 发布、同源 `/api/v1`、React Query 垂直切片及前后端测试模式,但尚无板块身份、板块成员快照、资金事实、雷达发布、显式 `last_good` 或图表基础设施。
- 当前 checkout 为 detached HEAD,提交 `ad9545e` 同时是本地与远端 `develop` 的头;新任务 `task.json` 尚未设置工作分支。进入实现前应创建或切换到任务分支并写入任务元数据,不改动现有 `08-27-onechartlab-research` 调研任务。
- 用户已选择 Tushare 独立生产 MVP,并确认具备所需 Tushare 权限。OneChartLab 公开 payload 只作为对账证据和测试样本来源,不作为生产运行时事实源。
## Requirements
### R1. 可追溯发布
每个可查询发布版本必须至少携带交易日、观测/发布时间、来源类型、来源版本、内容 hash、指标策略版本、universe 版本、数据状态和覆盖率。相同交易日允许存在修订版本,读取端必须能区分当前发布与最近一个有效发布。
### R2. 排名池与确定性算法
概念和行业必须独立排名,不能硬编码板块数量或成员关系。对公开证据已确认的算法必须提供确定性实现和回归测试:
- `Swing` 按 `Swing_Score` 降序,`Ratio` 按 `Ratio_Score` 降序,`Amount` 按 `Amount_Raw_BN` 降序;
- `RankPct = 100 * (N - rank + 1) / N`,排名从 1 开始;
- 普通强榜使用 `RankPct >= 90`,弱榜使用 `RankPct <= 10`;
- 排名变化为 `PastRank - CurrentRank`,正数表示上升;1—5 日历史不足或缺失时按明确契约处理;
- 排序必须确定性且不依赖输入遍历顺序;由于原站并列规则未知,本项目稳定键必须以独立实现契约命名并测试,不能标注为原站规则。
### R3. 未公开指标隔离
`Ratio`、`Swing` 及任何自建 score 的计算必须位于可替换指标策略 seam 后,并返回明确的 `metric_version`、参数、质量状态和所需输入。公开 payload 的预计算 score 与本项目独立策略不得混写为同一来源或同一版本。
### R4. Point-in-time 与空值语义
板块成员必须按交易日保存;缺失成员快照应为 `membership_unknown`,不得用当前成员回填历史。供应商缺行、NULL、数值 0、停牌、生命周期无效与低流动性必须保持不同语义,缺失资金流不得转成 0。
### R5. 单位与质量门
原始层保留供应商单位,规范层显式换算。MVP 使用的 `moneyflow_dc.net_amount` 为万元,`daily.amount` 为千元,两者必须先统一为元再计算比例。发布至少输出有效样本数、成员覆盖率、资金覆盖率和质量状态;未达完整性门槛时不得覆盖最近有效发布。
### R6. 读取契约与页面
后端在独立 bounded context 中提供稳定 Pydantic 响应,并经 `/api/v1` 同源路由暴露。前端在独立 feature 中使用 `requestJson`、React Query 和 URL/局部状态,显式呈现加载、错误、无数据、stale/partial 与成功状态。页面最少支持交易日、板块类型、指标视角、强弱榜切换、搜索和排名表。首个 MVP 不包含成分股详情、历史轨迹图或数据导出,但应保存足以计算 1—5 日排名变化的历史发布结果。
### R7. 收盘后执行与降级
生产流程沿用外部调度的一次性 Job,不在 FastAPI 生命周期内启动定时器。发布过程必须幂等并使用锁避免同一目标日期并发构建;上游未到齐或校验失败时保留 `last_good`,并把当前状态标记为 stale/partial/failed,不能发布假完整结果。
### R8. 安全与凭据
Tushare token 只从 `Settings`/环境注入,不得写入源码、日志、响应、测试 fixture 或 Trellis 任务文档。公开站点输入必须进行 schema 与有限数校验,不能仅依靠 TypeScript 泛型断言。
## Acceptance Criteria
- [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
- 盘中实时资金流、WebSocket 推送或分钟级雷达。
- 在没有第一方公式和 point-in-time 输入证据时宣称完全复刻 `Ratio_Score`、`Swing_Score`、`Amount_Score` 或 3—10 日权重。
- 使用当前板块成员回填历史、把缺失值补 0,或把 OneChartLab 当前 universe 数量硬编码进实现。
- 宏观择时模块、交易执行、收益承诺和投资建议。
- 在首个安全里程碑中一次性实现研究报告列出的全部 17 个 Tushare 接口。
- 首个 MVP 的成分股详情、排名轨迹图、导出、盘中刷新、`daily_basic` 流动性增强和 `moneyflow_ind_dc` 三方对账;这些作为后续增量,不阻塞主榜生产。
@@ -0,0 +1,32 @@
# 板块资金雷达实现依据
## 已选择的产品边界
用户选择 Tushare 独立生产 MVP,并确认具备 Tushare 权限。生产运行时不依赖 OneChartLab;公开 payload 只用于验证公开契约、构造固定样本和对账。未公开公式必须使用知行系统自己的策略名称与版本。
## 仓库复用点
- 后端采用 `modules/<bounded_context>/{domain,application,infrastructure,presentation}`,依据 `docs/adr/0001-bounded-context-first-modular-monolith.md` 和 `.trellis/spec/backend/directory-structure.md`。雷达应创建独立 bounded context。
- `market_data.infrastructure.tushare.RequestCoordinator` 已实现供应商请求冷却、退避和有限重试;`TushareAdapter` 已使用注入的 `pro_api(token)` client。相关实现位于 `zhixing-server/src/zhixing_server/modules/market_data/infrastructure/tushare.py:40-126,235-377`。
- `SyncMarketData` 已实现 advisory lock、批次审计、部分成功和定向重试,位于 `zhixing-server/src/zhixing_server/modules/market_data/application/sync.py:150-165,255-261,408-532`。
- PostgreSQL 适配器已有连接池、事务、staging + COPY 和幂等 upsert 模式,位于 `zhixing-server/src/zhixing_server/modules/market_data/infrastructure/postgres.py:26-57,296-401,769-925`。
- FastAPI 业务路由由 `zhixing-server/src/zhixing_server/interfaces/http/router.py:10-18` 统一挂载;浏览器固定使用同源 `/api/v1`。
- 前端垂直切片、路由和页面状态模式分别见 `zhixing-web/src/routes/route-tree.tsx:17-70`、`zhixing-web/src/features/home/api/`、`zhixing-web/src/features/home/pages/home-page.tsx`。当前没有图表依赖,因此首个 MVP 不加入轨迹图。
- `.codegraph/` 不存在,跨文件影响分析只能使用源码、测试和 `rg`。
## 当前 Tushare 客户端核验
项目声明 `tushare>=1.4.24`,见 `zhixing-server/pyproject.toml:7-16`。2026-08-28 通过 Context7 解析 `/waditu/tushare` 与 `/websites/tushare_pro`,确认:
- `ts.pro_api(token)` 创建 `DataApi`;客户端 `query(api_name, fields, **kwargs)` 把接口名、token、参数和字段列表发送到服务端。
- 客户端本身不执行积分、权限、频率或行数限制;这些限制由 Tushare 服务端返回。因此采集 Job 必须记录安全错误类别、响应行数和覆盖率,并在返回数接近单次上限时分片重拉。
- 不采用 `set_token()` 的用户目录持久化方式;项目继续通过 `Settings` 注入 token,避免凭据落盘或进入任务文档。
Context7 对具体板块接口的字段覆盖有限,接口字段、单位、历史边界和 2026-08-28 权限快照继续以 `docs/research/onechartlab-tushare-data-requirements.md` 所列 Tushare 第一方页面为实现依据。上线前由目标账号执行能力探测,不能把文档积分视为账号实测结果。
## 独立指标契约
- `zhixing_amount_net_bn_v1`:对当日有效成员的 `moneyflow_dc.net_amount` 求和,由万元除以 10,000 转为亿元;它是待公开样本对账的独立聚合,不宣称原站等价。
- `zhixing_ratio_turnover_v1`:先统一为元,再计算板块 `sum(net_amount) / sum(daily.amount)`;分母为零或输入缺失时返回 NULL。
- `zhixing_swing_equal_3_10_v1`:对窗口 3—10 个交易日分别计算 `sum(net_amount) / sum(turnover)`,再对八个完整窗口等权平均。任何窗口不完整时该指标不可用。该公式是透明、可替换的知行独立实现,不是 OneChartLab 的 3—10 日权重或 `Swing_Score`。
- 横截面排名按指标值降序、板块代码升序稳定打破并列;概念与行业独立成池。`RankPct`、TOP/BOTTOM 阈值和 `PastRank - CurrentRank` 复用公开确认算法。
@@ -0,0 +1,35 @@
# Tushare 板块雷达最小契约
本文件从 `docs/research/onechartlab-tushare-data-requirements.md` 提炼首个 Radar MVP 实际需要的接口,避免实现上下文被宏观择时和后续增强接口稀释。上线前仍以目标账号的只读 capability probe 为准。
## 最小接口
| 接口 | 作用 | 调用与必要字段 | 业务键与边界 |
| --- | --- | --- | --- |
| `trade_cal` | 确认开市日和窗口 | `exchange,start_date,end_date`;`exchange,cal_date,is_open,pretrade_date` | `(exchange,cal_date)`;初始化后按年刷新 |
| `dc_index` | 当日概念/行业 universe | `trade_date,idx_type`;`ts_code,trade_date,name,idx_type,level,pct_change,leading_code` | `(trade_date,ts_code)`;概念与行业分别拉取,单次上限公开页为 5,000 |
| `dc_member` | 当日 point-in-time 成员 | 优先 `trade_date`,必要时按 `ts_code` 分片;`trade_date,ts_code,con_code,name` | `(trade_date,ts_code,con_code)`;单次上限 5,000,命中上限或覆盖不足必须分片,不得用当前成员补历史 |
| `stock_basic` | 生命周期与市场过滤 | 分别拉 `list_status=L,D,P,G,UN`;`ts_code,symbol,name,market,exchange,list_status,list_date,delist_date` | `ts_code + observed_at`;默认只返回 L,不能漏掉其他状态 |
| `suspend_d` | 区分停牌与缺数 | `trade_date`;`ts_code,trade_date,suspend_timing,suspend_type` | `(ts_code,trade_date,suspend_type,suspend_timing)`;官方称不定期修订,需重叠回拉 |
| `daily` | 成交额、涨跌幅和行情可用性 | `trade_date`;`ts_code,trade_date,close,pre_close,pct_chg,vol,amount` | `(ts_code,trade_date)`;单次上限 6,000,停牌期间不返回;`amount` 单位千元 |
| `moneyflow_dc` | 个股东财口径主力净额 | `trade_date`;`trade_date,ts_code,name,net_amount,net_amount_rate,pct_change,close` | `(ts_code,trade_date)`;单次上限 6,000,历史始于 2023-09-11;`net_amount` 单位万元 |
## 单位与规范化
- `moneyflow_dc.net_amount` 万元转元时乘 `10_000`,转亿元时除 `10_000`。
- `daily.amount` 千元转元时乘 `1_000`,转亿元时除 `100_000`。
- `daily.pct_chg=1.5` 表示 1.5%;不与 OneChartLab 小数比例字段直接混算。
- 空字符串、`None` 和 `NaN` 规范为 NULL;`inf`、`-inf`、重复业务键和错误交易日属于硬错误。
- 缺失资金流不是 0。只有生命周期有效、非停牌且源接口应有记录的股票进入缺失率分母。
## Universe 与质量
- 只纳入沪深 A 股,排除北交所与沪深 B 股;OneChartLab Radar 契约未声明排除 ST,因此不能复用现有选股股票池的 ST 过滤。
- `dc_member` 某日缺失时标记 `membership_unknown`,不能向前或向后填充。
- 全局分别计算成员、`daily` 和 `moneyflow_dc` 覆盖率;publication 只有在全部必需接口通过、成员完整且事实覆盖率达到配置门槛时才为 success。
- 板块结果输出成员数、有效样本数、成交额、成员覆盖率与资金覆盖率;有效样本少于 5 时标记 `available_limited_sample`。
- 初始回填必须按交易日顺序完成至少 10 日,才能生成完整 3—10 日独立波段指标;`moneyflow_dc` 的最早日期是硬边界。
## 客户端与安全
项目使用 `ts.pro_api(token)` 返回的 `DataApi`,动态调用最终进入 `query(api_name, fields, **params)`。客户端不执行权限、积分、频率或行数保护,采集 adapter 必须处理服务端错误、退避、返回行数和覆盖率。token 只从 `Settings` 注入,不调用 `set_token()` 写用户目录,不写入日志、原始请求清单或错误摘要。
@@ -0,0 +1,26 @@
{
"id": "sector-capital-radar",
"name": "sector-capital-radar",
"title": "板块资金雷达模块",
"description": "基于 Tushare point-in-time 事实独立生产收盘后板块资金排名、版本化指标、last-good API 与前端页面。",
"status": "completed",
"dev_type": null,
"scope": "fullstack",
"package": null,
"priority": "P2",
"creator": "yuxuanhui",
"assignee": "yuxuanhui",
"createdAt": "2026-08-28",
"completedAt": "2026-08-29",
"branch": "codex/zijin",
"base_branch": "develop",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
+4 -3
View File
@@ -8,8 +8,8 @@
<!-- @@@auto:current-status -->
- **Active File**: `journal-1.md`
- **Total Sessions**: 9
- **Last Active**: 2026-08-12
- **Total Sessions**: 10
- **Last Active**: 2026-08-29
<!-- @@@/auto:current-status -->
---
@@ -19,7 +19,7 @@
<!-- @@@auto:active-documents -->
| File | Lines | Status |
|------|-------|--------|
| `journal-1.md` | ~243 | Active |
| `journal-1.md` | ~270 | Active |
<!-- @@@/auto:active-documents -->
---
@@ -29,6 +29,7 @@
<!-- @@@auto:session-history -->
| # | Date | Title | Commits | Branch |
|---|------|-------|---------|--------|
| 10 | 2026-08-29 | 完成板块资金雷达 Tushare 独立生产 MVP | `3789008`, `284c480`, `d9bae72`, `efc4c3d`, `8e96e64`, `23493fa`, `2fd16e5` | `codex/zijin` |
| 9 | 2026-08-12 | 完成选股执行性能优化 | `8963c06` | `develop` |
| 8 | 2026-08-11 | 完成市场数据同步与完整性检查 | `7ce1154`, `8f5f504` | `develop` |
| 7 | 2026-08-10 | 完成选股执行状态抽屉与紧凑布局 | `17237e0` | `develop` |
+27
View File
@@ -241,3 +241,30 @@
### Status
[OK] **Completed**
## Session 10: 完成板块资金雷达 Tushare 独立生产 MVP
**Date**: 2026-08-29
**Task**: 完成板块资金雷达 Tushare 独立生产 MVP
**Branch**: `codex/zijin`
### Summary
完成版本化独立指标、Tushare point-in-time 输入、可恢复构建 Job、last-good 查询 API 和前端排名工作台;补齐 membership_unknown、快照 identity、跨层百分位与完整验证契约。
### Git Commits
| Hash | Message |
|------|---------|
| `3789008` | (see git log) |
| `284c480` | (see git log) |
| `d9bae72` | (see git log) |
| `efc4c3d` | (see git log) |
| `8e96e64` | (see git log) |
| `23493fa` | (see git log) |
| `2fd16e5` | (see git log) |
### Status
[OK] **Completed**
+14
View File
@@ -50,3 +50,17 @@ _Avoid_: 用当前市值回填历史、把当前目标股票池当作无幸存
**股票更新失败**:一只股票未能在目标交易日同时具备日线行情和估值快照时的状态,并应保留可读的失败原因。
**数据覆盖率**:目标交易日内,有效选股股票数占当前目标股票池目标数的比例,用于判断选股结果是否具备足够完整性。
## 板块资金雷达
**板块资金雷达**:在交易日收盘后,分别对概念板块和行业板块的资金指标进行横截面比较、排名与历史变化分析;它不是盘中实时信号,也不构成交易指令。
**板块排名池**:同一交易日、同一板块类型中参加同一指标排名的板块集合;概念板块与行业板块属于不同排名池,成员数量随交易日变化。
**板块成员快照**:数据源在指定交易日给出的板块与股票成员关系。历史分析只使用对应交易日的快照,缺失时标记成员未知,不用当前成员替代。
**独立指标策略**:知行系统根据 Tushare 原始事实自行定义的资金指标算法;它必须有稳定名称和版本,不能称为外部站点未公开公式的复刻。
**雷达发布批次**:针对一个目标交易日完成的输入采集、质量校验、指标计算和排名发布;同一交易日可以因上游修订产生多个批次。
**最近有效发布**:最近一个通过完整性与质量门的雷达发布批次。新批次失败或只完成部分数据时,读取端继续使用该批次并明确显示数据已过期。
+27
View File
@@ -36,6 +36,11 @@ services:
ZHIXING_MARKET_DATA_RETRY_BACKOFF_SECONDS: ${ZHIXING_MARKET_DATA_RETRY_BACKOFF_SECONDS:-1.0}
ZHIXING_MARKET_DATA_REQUEST_INTERVAL_SECONDS: ${ZHIXING_MARKET_DATA_REQUEST_INTERVAL_SECONDS:-0.2}
ZHIXING_MARKET_DATA_ADVISORY_LOCK_KEY: ${ZHIXING_MARKET_DATA_ADVISORY_LOCK_KEY:-7380521}
ZHIXING_SECTOR_RADAR_COVERAGE_THRESHOLD: ${ZHIXING_SECTOR_RADAR_COVERAGE_THRESHOLD:-0.99}
ZHIXING_SECTOR_RADAR_MAX_RETRIES: ${ZHIXING_SECTOR_RADAR_MAX_RETRIES:-3}
ZHIXING_SECTOR_RADAR_RETRY_BACKOFF_SECONDS: ${ZHIXING_SECTOR_RADAR_RETRY_BACKOFF_SECONDS:-1.0}
ZHIXING_SECTOR_RADAR_REQUEST_INTERVAL_SECONDS: ${ZHIXING_SECTOR_RADAR_REQUEST_INTERVAL_SECONDS:-0.2}
ZHIXING_SECTOR_RADAR_ADVISORY_LOCK_KEY: ${ZHIXING_SECTOR_RADAR_ADVISORY_LOCK_KEY:-7380522}
ZHIXING_SELECTION_MAX_WORKERS: ${ZHIXING_SELECTION_MAX_WORKERS:-4}
ZHIXING_SELECTION_BATCH_SIZE: ${ZHIXING_SELECTION_BATCH_SIZE:-200}
ZHIXING_TUSHARE_TOKEN: ${ZHIXING_TUSHARE_TOKEN:-}
@@ -112,6 +117,28 @@ services:
- market-data:/app/data/market-data
- server-venv:/app/.venv
sector-radar-build:
profiles: ["jobs"]
build:
context: ./zhixing-server
target: development
entrypoint: ["uv", "run", "sector-radar-build"]
command: []
depends_on:
migrate:
condition: service_completed_successfully
environment:
ZHIXING_DATABASE_URL: ${ZHIXING_DATABASE_URL:-postgresql://zhixing:zhixing@postgres:5432/zhixing}
ZHIXING_SECTOR_RADAR_COVERAGE_THRESHOLD: ${ZHIXING_SECTOR_RADAR_COVERAGE_THRESHOLD:-0.99}
ZHIXING_SECTOR_RADAR_MAX_RETRIES: ${ZHIXING_SECTOR_RADAR_MAX_RETRIES:-3}
ZHIXING_SECTOR_RADAR_RETRY_BACKOFF_SECONDS: ${ZHIXING_SECTOR_RADAR_RETRY_BACKOFF_SECONDS:-1.0}
ZHIXING_SECTOR_RADAR_REQUEST_INTERVAL_SECONDS: ${ZHIXING_SECTOR_RADAR_REQUEST_INTERVAL_SECONDS:-0.2}
ZHIXING_SECTOR_RADAR_ADVISORY_LOCK_KEY: ${ZHIXING_SECTOR_RADAR_ADVISORY_LOCK_KEY:-7380522}
ZHIXING_TUSHARE_TOKEN: ${ZHIXING_TUSHARE_TOKEN:-}
volumes:
- ./zhixing-server:/app
- server-venv:/app/.venv
volumes:
market-data:
postgres-data:
+27
View File
@@ -18,6 +18,11 @@ services:
ZHIXING_MARKET_DATA_RETRY_BACKOFF_SECONDS: ${ZHIXING_MARKET_DATA_RETRY_BACKOFF_SECONDS:-1.0}
ZHIXING_MARKET_DATA_REQUEST_INTERVAL_SECONDS: ${ZHIXING_MARKET_DATA_REQUEST_INTERVAL_SECONDS:-0.2}
ZHIXING_MARKET_DATA_ADVISORY_LOCK_KEY: ${ZHIXING_MARKET_DATA_ADVISORY_LOCK_KEY:-7380521}
ZHIXING_SECTOR_RADAR_COVERAGE_THRESHOLD: ${ZHIXING_SECTOR_RADAR_COVERAGE_THRESHOLD:-0.99}
ZHIXING_SECTOR_RADAR_MAX_RETRIES: ${ZHIXING_SECTOR_RADAR_MAX_RETRIES:-3}
ZHIXING_SECTOR_RADAR_RETRY_BACKOFF_SECONDS: ${ZHIXING_SECTOR_RADAR_RETRY_BACKOFF_SECONDS:-1.0}
ZHIXING_SECTOR_RADAR_REQUEST_INTERVAL_SECONDS: ${ZHIXING_SECTOR_RADAR_REQUEST_INTERVAL_SECONDS:-0.2}
ZHIXING_SECTOR_RADAR_ADVISORY_LOCK_KEY: ${ZHIXING_SECTOR_RADAR_ADVISORY_LOCK_KEY:-7380522}
ZHIXING_SELECTION_MAX_WORKERS: ${ZHIXING_SELECTION_MAX_WORKERS:-4}
ZHIXING_SELECTION_BATCH_SIZE: ${ZHIXING_SELECTION_BATCH_SIZE:-200}
ZHIXING_TUSHARE_TOKEN: ${ZHIXING_TUSHARE_TOKEN:-}
@@ -111,6 +116,28 @@ services:
networks:
- 1panel-network
sector-radar-build:
profiles: ["jobs"]
build:
context: ./zhixing-server
target: production
entrypoint: ["sector-radar-build"]
command: []
depends_on:
migrate:
condition: service_completed_successfully
environment:
TZ: Asia/Shanghai
ZHIXING_DATABASE_URL: ${ZHIXING_DATABASE_URL:?Set ZHIXING_DATABASE_URL to the 1Panel PostgreSQL URL}
ZHIXING_SECTOR_RADAR_COVERAGE_THRESHOLD: ${ZHIXING_SECTOR_RADAR_COVERAGE_THRESHOLD:-0.99}
ZHIXING_SECTOR_RADAR_MAX_RETRIES: ${ZHIXING_SECTOR_RADAR_MAX_RETRIES:-3}
ZHIXING_SECTOR_RADAR_RETRY_BACKOFF_SECONDS: ${ZHIXING_SECTOR_RADAR_RETRY_BACKOFF_SECONDS:-1.0}
ZHIXING_SECTOR_RADAR_REQUEST_INTERVAL_SECONDS: ${ZHIXING_SECTOR_RADAR_REQUEST_INTERVAL_SECONDS:-0.2}
ZHIXING_SECTOR_RADAR_ADVISORY_LOCK_KEY: ${ZHIXING_SECTOR_RADAR_ADVISORY_LOCK_KEY:-7380522}
ZHIXING_TUSHARE_TOKEN: ${ZHIXING_TUSHARE_TOKEN:-}
networks:
- 1panel-network
volumes:
market-data:
+26
View File
@@ -95,3 +95,29 @@ docker compose -f docker-compose.prod.yml --profile jobs config
```
真实 PostgreSQL 迁移和批量 upsert 集成测试使用 `ZHIXING_TEST_DATABASE_URL` 显式开启;普通单元测试不会访问网络、Tushare 或数据库。
## 板块资金雷达 Job
`sector-radar-build` 同样是外部调度器触发的一次性任务,FastAPI 不会在进程内启动定时器。它只读取 Tushare 的 `trade_cal`、`dc_index`、`dc_member`、`stock_basic`、`suspend_d`、`daily` 和 `moneyflow_dc`,保存 point-in-time 原始快照与规范化事实,再生成明确标注为“知行独立实现”的版本化指标。生产运行时不请求 OneChartLab。
开发环境没有 token 时可以检查命令契约,但不能执行真实构建:
```bash
cd zhixing-server
uv run sector-radar-build --help
```
提供 `ZHIXING_TUSHARE_TOKEN` 并完成迁移后,可构建单日、按交易日顺序回填区间,或从一个 `partial`/`failed` publication 的来源检查点继续重试。失败 publication 会复用此前已成功保存的来源组;覆盖率不足的 partial 只刷新被标记为缺口的 `daily` 或 `moneyflow_dc`,不会全量重采:
```bash
docker compose -f docker-compose.prod.yml --profile jobs run --rm sector-radar-build \
--trade-date 2026-08-28
docker compose -f docker-compose.prod.yml --profile jobs run --rm sector-radar-build \
--start-date 2026-08-18 --end-date 2026-08-28
docker compose -f docker-compose.prod.yml --profile jobs run --rm sector-radar-build \
--retry-publication-id <publication-id>
```
重复输入通过内容 hash 复用已有成功发布,不产生无意义修订;同一目标日由 PostgreSQL advisory lock 阻止并发构建。`success` 或 `unchanged` 返回 0,覆盖率不足的 `partial` 返回 2,输入、上游、锁或基础设施失败返回 1。`partial`/`failed` 会保留审计,但读取端只选择 `success` 作为 last-good。当前版本只提供手工和外部调度入口,不新增生产 Cron;待真实账号 capability、到达时点和首轮回填验证完成后再单独启用调度。
@@ -0,0 +1,234 @@
"""Create replayable independent sector radar tables."""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import JSONB
revision: str = "0004_sector_radar"
down_revision: str | None = "0003_market_integrity_checks"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Create source, point-in-time fact, publication, and ranking tables."""
op.create_table(
"sector_radar_source_snapshot",
sa.Column("id", sa.String(64), primary_key=True),
sa.Column("api_name", sa.String(32), nullable=False),
sa.Column("normalized_params", JSONB, nullable=False),
sa.Column("target_trade_date", sa.Date()),
sa.Column("partition_key", sa.String(64)),
sa.Column("observed_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("payload", JSONB, nullable=False),
sa.Column("row_count", sa.Integer(), nullable=False),
sa.Column("returned_fields", JSONB, nullable=False),
sa.Column("content_sha256", sa.String(64), nullable=False),
sa.Column("row_limit", sa.Integer()),
sa.Column("limit_reached", sa.Boolean(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.CheckConstraint("row_count >= 0", name="ck_sector_radar_source_row_count"),
sa.CheckConstraint(
"row_limit IS NULL OR row_limit > 0", name="ck_sector_radar_source_limit"
),
)
op.create_index(
"ix_sector_radar_source_api_date",
"sector_radar_source_snapshot",
["api_name", "target_trade_date", "observed_at"],
)
op.create_table(
"sector_radar_membership",
sa.Column(
"source_snapshot_id",
sa.String(64),
sa.ForeignKey("sector_radar_source_snapshot.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("trade_date", sa.Date(), nullable=False),
sa.Column("sector_type", sa.String(16), nullable=False),
sa.Column("sector_code", sa.String(16), nullable=False),
sa.Column("sector_name", sa.String(128), nullable=False),
sa.Column("stock_code", sa.String(12), nullable=False),
sa.Column("stock_name", sa.String(128), nullable=False),
sa.Column("membership_status", sa.String(32), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.PrimaryKeyConstraint("source_snapshot_id", "sector_code", "stock_code"),
sa.CheckConstraint(
"sector_type IN ('concept', 'industry')",
name="ck_sector_radar_membership_type",
),
sa.CheckConstraint(
"membership_status = 'available'",
name="ck_sector_radar_membership_status",
),
)
op.create_index(
"ix_sector_radar_membership_date_sector",
"sector_radar_membership",
["trade_date", "sector_type", "sector_code"],
)
op.create_table(
"sector_radar_stock_fact",
sa.Column("fact_revision", sa.String(64), nullable=False),
sa.Column("trade_date", sa.Date(), nullable=False),
sa.Column("ts_code", sa.String(12), nullable=False),
sa.Column("source_snapshot_ids", JSONB, nullable=False),
sa.Column("status", sa.String(32), nullable=False),
sa.Column("turnover_yuan", sa.Numeric(28, 6)),
sa.Column("net_amount_yuan", sa.Numeric(28, 6)),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.PrimaryKeyConstraint("fact_revision", "ts_code"),
sa.CheckConstraint(
"turnover_yuan IS NULL OR turnover_yuan >= 0",
name="ck_sector_radar_stock_turnover",
),
)
op.create_index(
"ix_sector_radar_stock_fact_date",
"sector_radar_stock_fact",
["trade_date", "ts_code"],
)
op.create_table(
"sector_radar_publication",
sa.Column("id", sa.String(64), primary_key=True),
sa.Column("target_trade_date", sa.Date(), nullable=False),
sa.Column("status", sa.String(16), nullable=False),
sa.Column("source_version", sa.String(128), nullable=False),
sa.Column("universe_version", sa.String(128), nullable=False),
sa.Column("metric_versions", JSONB, nullable=False),
sa.Column("input_hash", sa.String(64)),
sa.Column("coverage", sa.Numeric(8, 6), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("finished_at", sa.DateTime(timezone=True)),
sa.Column("error_summary", sa.String(500)),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.CheckConstraint(
"status IN ('running', 'success', 'partial', 'failed')",
name="ck_sector_radar_publication_status",
),
sa.CheckConstraint(
"coverage >= 0 AND coverage <= 1",
name="ck_sector_radar_publication_coverage",
),
sa.CheckConstraint(
"(status = 'running' AND finished_at IS NULL) OR "
"(status <> 'running' AND finished_at IS NOT NULL)",
name="ck_sector_radar_publication_finished",
),
)
op.create_index(
"ix_sector_radar_publication_status_date",
"sector_radar_publication",
["status", "target_trade_date", "finished_at"],
)
op.create_index(
"uq_sector_radar_publication_running_date",
"sector_radar_publication",
["target_trade_date"],
unique=True,
postgresql_where=sa.text("status = 'running'"),
)
op.create_table(
"sector_radar_ranking",
sa.Column(
"publication_id",
sa.String(64),
sa.ForeignKey("sector_radar_publication.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("trade_date", sa.Date(), nullable=False),
sa.Column("sector_type", sa.String(16), nullable=False),
sa.Column("sector_code", sa.String(16), nullable=False),
sa.Column("sector_name", sa.String(128), nullable=False),
sa.Column("metric_kind", sa.String(16), nullable=False),
sa.Column("metric_version", sa.String(128), nullable=False),
sa.Column("implementation_kind", sa.String(16), nullable=False),
sa.Column("unit", sa.String(16), nullable=False),
sa.Column("metric_value", sa.Numeric(28, 12)),
sa.Column("quality", sa.String(32), nullable=False),
sa.Column("member_count", sa.Integer(), nullable=False),
sa.Column("valid_sample_count", sa.Integer(), nullable=False),
sa.Column("membership_coverage", sa.Numeric(8, 6), nullable=False),
sa.Column("moneyflow_coverage", sa.Numeric(8, 6), nullable=False),
sa.Column("rank_position", sa.Integer()),
sa.Column("rank_percentile", sa.Numeric(18, 12)),
sa.Column("rank_changes", JSONB, nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.PrimaryKeyConstraint(
"publication_id",
"sector_type",
"sector_code",
"metric_version",
),
sa.CheckConstraint(
"sector_type IN ('concept', 'industry')",
name="ck_sector_radar_ranking_type",
),
sa.CheckConstraint(
"implementation_kind = 'independent'",
name="ck_sector_radar_ranking_implementation",
),
)
op.create_index(
"ix_sector_radar_ranking_query",
"sector_radar_ranking",
["publication_id", "sector_type", "metric_version", "rank_position"],
)
def downgrade() -> None:
"""Drop only sector radar tables in dependency-safe order."""
op.drop_index("ix_sector_radar_ranking_query", table_name="sector_radar_ranking")
op.drop_table("sector_radar_ranking")
op.drop_index(
"uq_sector_radar_publication_running_date",
table_name="sector_radar_publication",
)
op.drop_index(
"ix_sector_radar_publication_status_date",
table_name="sector_radar_publication",
)
op.drop_table("sector_radar_publication")
op.drop_index("ix_sector_radar_stock_fact_date", table_name="sector_radar_stock_fact")
op.drop_table("sector_radar_stock_fact")
op.drop_index(
"ix_sector_radar_membership_date_sector",
table_name="sector_radar_membership",
)
op.drop_table("sector_radar_membership")
op.drop_index("ix_sector_radar_source_api_date", table_name="sector_radar_source_snapshot")
op.drop_table("sector_radar_source_snapshot")
@@ -0,0 +1,155 @@
"""Persist publication-owned sector daily aggregates for exact metric replay."""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0005_radar_daily_aggregate"
down_revision: str | None = "0004_sector_radar"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Create source recovery links and exact multi-day metric inputs."""
op.create_table(
"sector_radar_publication_source",
sa.Column(
"publication_id",
sa.String(64),
sa.ForeignKey("sector_radar_publication.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("source_group", sa.String(32), nullable=False),
sa.Column("source_order", sa.Integer(), nullable=False),
sa.Column(
"refresh_on_retry",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
sa.Column(
"source_snapshot_id",
sa.String(64),
sa.ForeignKey("sector_radar_source_snapshot.id", ondelete="RESTRICT"),
nullable=False,
),
sa.PrimaryKeyConstraint("publication_id", "source_group", "source_order"),
sa.UniqueConstraint(
"publication_id",
"source_group",
"source_snapshot_id",
name="uq_sector_radar_publication_source_snapshot",
),
sa.CheckConstraint(
"source_group IN ('calendar', 'concept_indices', 'industry_indices', "
"'members', 'stock_basics', 'suspensions', 'daily', 'moneyflow_dc')",
name="ck_sector_radar_publication_source_group",
),
sa.CheckConstraint(
"source_order >= 0",
name="ck_sector_radar_publication_source_order",
),
)
op.create_index(
"ix_sector_radar_publication_source_snapshot",
"sector_radar_publication_source",
["source_snapshot_id"],
)
op.create_unique_constraint(
"uq_sector_radar_publication_id_date",
"sector_radar_publication",
["id", "target_trade_date"],
)
op.create_foreign_key(
"fk_sector_radar_ranking_publication_date",
"sector_radar_ranking",
"sector_radar_publication",
["publication_id", "trade_date"],
["id", "target_trade_date"],
ondelete="CASCADE",
)
op.create_table(
"sector_radar_daily_aggregate",
sa.Column("publication_id", sa.String(64), nullable=False),
sa.Column("trade_date", sa.Date(), nullable=False),
sa.Column("sector_type", sa.String(16), nullable=False),
sa.Column("sector_code", sa.String(16), nullable=False),
sa.Column("sector_name", sa.String(128), nullable=False),
sa.Column("member_count", sa.Integer(), nullable=False),
sa.Column("valid_sample_count", sa.Integer(), nullable=False),
sa.Column("net_amount_yuan", sa.Numeric(28, 6)),
sa.Column("turnover_yuan", sa.Numeric(28, 6)),
sa.Column("membership_coverage", sa.Numeric(8, 6), nullable=False),
sa.Column("moneyflow_coverage", sa.Numeric(8, 6), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.PrimaryKeyConstraint("publication_id", "sector_type", "sector_code"),
sa.ForeignKeyConstraint(
["publication_id", "trade_date"],
["sector_radar_publication.id", "sector_radar_publication.target_trade_date"],
name="fk_sector_radar_daily_aggregate_publication_date",
ondelete="CASCADE",
),
sa.CheckConstraint(
"sector_type IN ('concept', 'industry')",
name="ck_sector_radar_daily_aggregate_type",
),
sa.CheckConstraint(
"member_count >= 0 AND valid_sample_count >= 0 AND valid_sample_count <= member_count",
name="ck_sector_radar_daily_aggregate_counts",
),
sa.CheckConstraint(
"membership_coverage >= 0 AND membership_coverage <= 1 "
"AND moneyflow_coverage >= 0 AND moneyflow_coverage <= 1",
name="ck_sector_radar_daily_aggregate_coverage",
),
sa.CheckConstraint(
"turnover_yuan IS NULL OR (turnover_yuan >= 0 AND "
"turnover_yuan NOT IN ('NaN'::numeric, 'Infinity'::numeric))",
name="ck_sector_radar_daily_aggregate_turnover",
),
sa.CheckConstraint(
"net_amount_yuan IS NULL OR net_amount_yuan NOT IN "
"('NaN'::numeric, 'Infinity'::numeric, '-Infinity'::numeric)",
name="ck_sector_radar_daily_aggregate_net_amount",
),
)
op.create_index(
"ix_sector_radar_daily_aggregate_history",
"sector_radar_daily_aggregate",
["trade_date", "sector_type", "sector_code"],
)
def downgrade() -> None:
"""Drop only the replay aggregate extension."""
op.drop_index(
"ix_sector_radar_daily_aggregate_history",
table_name="sector_radar_daily_aggregate",
)
op.drop_table("sector_radar_daily_aggregate")
op.drop_constraint(
"fk_sector_radar_ranking_publication_date",
"sector_radar_ranking",
type_="foreignkey",
)
op.drop_constraint(
"uq_sector_radar_publication_id_date",
"sector_radar_publication",
type_="unique",
)
op.drop_index(
"ix_sector_radar_publication_source_snapshot",
table_name="sector_radar_publication_source",
)
op.drop_table("sector_radar_publication_source")
@@ -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'",
)
+1
View File
@@ -35,6 +35,7 @@ packages = ["src/zhixing_server"]
[project.scripts]
market-data-sync = "zhixing_server.modules.market_data.presentation.cli:main"
sector-radar-build = "zhixing_server.modules.sector_radar.presentation.cli:main"
[tool.pytest.ini_options]
addopts = "-ra --strict-config --strict-markers"
@@ -24,6 +24,11 @@ class Settings(BaseSettings):
market_data_max_retries: int = 3
market_data_retry_backoff_seconds: float = 1.0
market_data_advisory_lock_key: int = 7_380_521
sector_radar_coverage_threshold: Decimal = Decimal("0.99")
sector_radar_request_interval_seconds: float = 0.2
sector_radar_max_retries: int = 3
sector_radar_retry_backoff_seconds: float = 1.0
sector_radar_advisory_lock_key: int = 7_380_522
selection_max_workers: int = Field(default=4, ge=1)
selection_batch_size: int = Field(default=200, ge=1)
@@ -5,6 +5,7 @@ from fastapi import APIRouter
from zhixing_server.interfaces.http.system import operational_router, system_router
from zhixing_server.modules.market_data.presentation.home import home_router
from zhixing_server.modules.market_data.presentation.integrity import integrity_router
from zhixing_server.modules.sector_radar.presentation.http import sector_radar_router
from zhixing_server.modules.selection.presentation.http import selection_router
api_v1_router = APIRouter(prefix="/api/v1")
@@ -16,5 +17,10 @@ api_v1_router.include_router(
tags=["market-data"],
)
api_v1_router.include_router(selection_router, prefix="/selection", tags=["selection"])
api_v1_router.include_router(
sector_radar_router,
prefix="/sector-radar",
tags=["sector-radar"],
)
__all__ = ["api_v1_router", "operational_router"]
@@ -2,186 +2,28 @@
from __future__ import annotations
import logging
import random
import threading
import time
from collections.abc import Callable, Iterable, Mapping, Sequence
from datetime import date
from typing import cast
from zhixing_server.shared.request_coordinator import (
DEFAULT_RATE_LIMIT_COOLDOWNS,
RequestCoordinator,
TushareRequestCoordinator,
TushareSourceError,
)
from ..domain.models import Bar, DailyBasic, Stock, SyncWindow, parse_date
from ..domain.rules import filter_current_hs_a_stocks
logger = logging.getLogger(__name__)
DEFAULT_RATE_LIMIT_COOLDOWNS = (60.0, 120.0, 180.0)
_RATE_LIMIT_MESSAGES = (
"访问频繁",
"请稍后",
"超过频率",
"频率限制",
"too many requests",
"rate limit",
"rate_limit",
"http 429",
"status code: 429",
"429",
"http 403",
"status code: 403",
"403",
)
class TushareSourceError(RuntimeError):
"""A vendor request failed after the configured retry budget."""
class RequestCoordinator:
"""Coordinate retry and shared rate-limit cooling for one token client.
Normal requests are deliberately not serialized. Only a provider rate
limit creates a shared cooldown, so independent worker calls can proceed
concurrently during ordinary traffic. ``clock`` and ``wait_fn`` are
injectable to make long cooldown behavior deterministic in unit tests.
"""
def __init__(
self,
*,
max_retries: int = 3,
backoff_seconds: float = 1.0,
cooldown_seconds: Sequence[float] = DEFAULT_RATE_LIMIT_COOLDOWNS,
random_fn: Callable[[], float] = random.random,
clock: Callable[[], float] = time.monotonic,
wait_fn: Callable[[float], None] = time.sleep,
sleep_fn: Callable[[float], None] | None = None,
) -> None:
cooldowns = tuple(float(value) for value in cooldown_seconds)
if not cooldowns or any(value < 0 for value in cooldowns):
raise ValueError("cooldown_seconds must contain non-negative values")
self.max_retries = max(0, max_retries)
self.backoff_seconds = max(0.0, backoff_seconds)
self.cooldown_seconds = cooldowns
self.random_fn = random_fn
self.clock = clock
self.wait_fn = wait_fn
self.sleep_fn = sleep_fn or wait_fn
self._condition = threading.Condition()
self._cooldown_until = 0.0
self._rate_limit_count = 0
@property
def cooldown_until(self) -> float:
"""Return the current monotonic cooldown deadline."""
with self._condition:
return self._cooldown_until
def call(self, method_name: str, request: Callable[[], object]) -> object:
"""Execute one provider request with bounded, shared retry behavior."""
last_error: BaseException | None = None
for attempt in range(self.max_retries + 1):
self._wait_for_cooldown(method_name)
try:
result = request()
except Exception as exc:
last_error = exc
if self.is_rate_limited(exc):
cooldown = self._set_rate_limit_cooldown()
logger.warning(
"tushare_rate_limit method=%s attempt=%d max_attempts=%d "
"cooldown_seconds=%.1f",
method_name,
attempt + 1,
self.max_retries + 1,
cooldown,
)
if attempt < self.max_retries:
continue
break
if not self._is_retryable(exc):
raise
if attempt == self.max_retries:
break
delay = self.backoff_seconds * (2**attempt) * (0.5 + self.random_fn())
logger.warning(
"tushare_request_retry method=%s attempt=%d max_attempts=%d "
"backoff_seconds=%.1f",
method_name,
attempt + 1,
self.max_retries + 1,
delay,
)
self.sleep_fn(delay)
else:
self._clear_rate_limit_after_success()
return result
logger.error(
"tushare_request_failed method=%s attempts=%d",
method_name,
self.max_retries + 1,
)
raise TushareSourceError(f"Tushare request failed: {method_name}") from last_error
def request(self, method_name: str, operation: Callable[[], object]) -> object:
"""Alias for ``call`` for adapters that model requests as a port."""
return self.call(method_name, operation)
def _wait_for_cooldown(self, method_name: str) -> None:
while True:
with self._condition:
delay = self._cooldown_until - self.clock()
if delay <= 0:
return
logger.info(
"tushare_rate_limit_wait method=%s wait_seconds=%.1f",
method_name,
delay,
)
# A single injected wait hook makes fake-clock tests independent
# from wall time. After waiting, re-check because another worker
# may have extended the shared deadline.
self.wait_fn(delay)
def _set_rate_limit_cooldown(self) -> float:
with self._condition:
self._rate_limit_count += 1
index = min(self._rate_limit_count - 1, len(self.cooldown_seconds) - 1)
duration = self.cooldown_seconds[index]
self._cooldown_until = max(self._cooldown_until, self.clock() + duration)
self._condition.notify_all()
return duration
def _clear_rate_limit_after_success(self) -> None:
with self._condition:
# A request that was already in flight when another worker hit a
# limit may succeed during the shared cooldown. Do not erase the
# escalation history until the cooldown has actually elapsed.
if self.clock() >= self._cooldown_until:
self._rate_limit_count = 0
@staticmethod
def is_rate_limited(error: BaseException) -> bool:
"""Classify stable provider rate-limit signals without logging details."""
for attribute in ("status_code", "status", "code"):
value = getattr(error, attribute, None)
if str(value).strip() in {"403", "429"}:
return True
message = str(error).casefold()
return any(marker.casefold() in message for marker in _RATE_LIMIT_MESSAGES)
@staticmethod
def _is_retryable(error: BaseException) -> bool:
return isinstance(error, (OSError, RuntimeError, TimeoutError))
# The longer name is useful to callers that want to make the infrastructure
# boundary explicit, while the short name remains convenient in unit tests.
TushareRequestCoordinator = RequestCoordinator
__all__ = [
"RequestCoordinator",
"TushareAdapter",
"TushareRequestCoordinator",
"TushareSourceError",
]
class CoordinatedTushareClient:
@@ -0,0 +1 @@
"""Independent post-close sector capital radar bounded context."""
@@ -0,0 +1 @@
"""Application use cases for sector radar production and reads."""
@@ -0,0 +1,728 @@
"""One-shot, idempotent sector radar publication orchestration."""
from __future__ import annotations
import hashlib
import json
from collections import defaultdict
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass, replace
from datetime import UTC, date, datetime, time, timedelta
from decimal import Decimal
from typing import Literal
from uuid import uuid4
from zoneinfo import ZoneInfo
from ..domain.facts import aggregate_sector_snapshot
from ..domain.metrics import (
AmountNetStrategy,
MetricStrategy,
RatioTurnoverStrategy,
SwingEqualThreeToTenStrategy,
)
from ..domain.models import (
MembershipStatus,
MetricObservation,
PublicationStatus,
RadarPublication,
RankedMetric,
SectorDailyAggregate,
SectorMembershipSnapshot,
SectorType,
StockDailyFact,
StockFactStatus,
)
from ..domain.normalize import normalize_memberships, normalize_stock_facts
from ..domain.persistence import (
DailyAggregateRecord,
MembershipRecord,
PublicationSourceGroup,
PublicationSourceRecord,
RankingRecord,
SectorRadarRepository,
StockFactRecord,
)
from ..domain.ports import SectorRadarSource
from ..domain.ranking import rank_metric_observations, with_rank_changes
from ..domain.source import (
DailyRow,
MoneyflowDcRow,
SectorIndexRow,
SectorMemberRow,
SourceResult,
SourceScalar,
SourceSnapshot,
StockBasicRow,
SuspendRow,
TradeCalendarRow,
)
BuildOutcomeStatus = Literal["success", "partial", "failed", "locked", "unchanged"]
SHANGHAI = ZoneInfo("Asia/Shanghai")
MARKET_DATA_READY_TIME = time(15, 30)
@dataclass(frozen=True, slots=True)
class BuildSectorRadarCommand:
"""Select one date, an inclusive range, or a failed publication retry."""
trade_date: date | None = None
start_date: date | None = None
end_date: date | None = None
retry_publication_id: str | None = None
def __post_init__(self) -> None:
"""Reject ambiguous build modes before any source or database call."""
has_range = self.start_date is not None or self.end_date is not None
modes = sum(
(
self.trade_date is not None,
has_range,
self.retry_publication_id is not None,
)
)
if modes > 1:
raise ValueError("trade date, date range, and retry publication are mutually exclusive")
if has_range and (self.start_date is None or self.end_date is None):
raise ValueError("date range requires both start_date and end_date")
if (
self.start_date is not None
and self.end_date is not None
and self.end_date < self.start_date
):
raise ValueError("end_date must not precede start_date")
if self.retry_publication_id is not None and not self.retry_publication_id.strip():
raise ValueError("retry_publication_id must not be empty")
@dataclass(frozen=True, slots=True)
class BuildDateOutcome:
"""Redacted result for one target trade date."""
target_trade_date: date
status: BuildOutcomeStatus
publication_id: str | None
coverage: Decimal
sector_count: int
ranking_count: int
error_type: str | None = None
error_message: str | None = None
def as_dict(self) -> dict[str, object]:
"""Serialize without raw payloads, credentials, or provider exception text."""
return {
"target_trade_date": self.target_trade_date.isoformat(),
"status": self.status,
"publication_id": self.publication_id,
"coverage": str(self.coverage),
"sector_count": self.sector_count,
"ranking_count": self.ranking_count,
"error_type": self.error_type,
"error_message": self.error_message,
}
@dataclass(frozen=True, slots=True)
class BuildSummary:
"""Cron-friendly aggregate result for one CLI invocation."""
outcomes: tuple[BuildDateOutcome, ...]
@property
def status(self) -> str:
"""Return the worst invocation state."""
if not self.outcomes or any(item.status in {"failed", "locked"} for item in self.outcomes):
return "failed"
if any(item.status == "partial" for item in self.outcomes):
return "partial"
if all(item.status == "unchanged" for item in self.outcomes):
return "unchanged"
return "success"
@property
def exit_code(self) -> int:
"""Return 0 for usable success, 2 for incomplete input, and 1 for failure."""
if self.status == "failed":
return 1
if self.status == "partial":
return 2
return 0
def as_dict(self) -> dict[str, object]:
"""Serialize the invocation summary for external schedulers."""
return {
"status": self.status,
"exit_code": self.exit_code,
"outcomes": [item.as_dict() for item in self.outcomes],
}
class BuildSectorRadar:
"""Hide target resolution, source replay, metrics, ranking, and publication switching."""
source_version = "tushare-pro-v1"
def __init__(
self,
source: SectorRadarSource,
repository: SectorRadarRepository,
*,
coverage_threshold: Decimal = Decimal("0.99"),
today: date | None = None,
now_fn: Callable[[], datetime] = lambda: datetime.now(UTC),
strategies: Sequence[MetricStrategy] | None = None,
) -> None:
if not Decimal(0) <= coverage_threshold <= Decimal(1):
raise ValueError("coverage_threshold must be between 0 and 1")
self.now_fn = now_fn
self.source = source
self.repository = repository
self.coverage_threshold = coverage_threshold
self.today = today or self.now_fn().astimezone(SHANGHAI).date()
self.strategies = tuple(
strategies
or (
AmountNetStrategy(),
RatioTurnoverStrategy(),
SwingEqualThreeToTenStrategy(),
)
)
def execute(self, command: BuildSectorRadarCommand | None = None) -> BuildSummary:
"""Build each selected trade date sequentially for deterministic history."""
command = command or BuildSectorRadarCommand()
try:
targets = self._resolve_targets(command)
except Exception as exc:
target = command.trade_date or command.start_date or self.today
error_type, message = self._safe_failure(exc)
return BuildSummary(
(
BuildDateOutcome(
target,
"failed",
None,
Decimal(0),
0,
0,
error_type,
message,
),
)
)
return BuildSummary(tuple(self._build_target(target) for target in targets))
def _resolve_targets(self, command: BuildSectorRadarCommand) -> tuple[_BuildTarget, ...]:
if command.retry_publication_id is not None:
publication = self.repository.get_publication(command.retry_publication_id)
if publication is None:
raise ValueError("retry publication does not exist")
if publication.status not in {PublicationStatus.PARTIAL, PublicationStatus.FAILED}:
raise ValueError("only partial or failed publications can be retried")
return (_BuildTarget(publication.target_trade_date, publication.publication_id),)
if command.trade_date is not None:
start = end = command.trade_date
elif command.start_date is not None and command.end_date is not None:
start, end = command.start_date, command.end_date
else:
end = self._default_calendar_end()
start = end - timedelta(days=14)
calendar = self.source.fetch_trade_calendar(start, end)
targets = tuple(sorted({row.cal_date for row in calendar.rows if row.is_open}))
if command.trade_date is not None and command.trade_date not in targets:
raise ValueError("target date is not an open trading day")
if not targets:
raise ValueError("no open trading date found")
selected = targets if command.start_date is not None else (targets[-1],)
return tuple(_BuildTarget(target) for target in selected)
def _default_calendar_end(self) -> date:
"""Exclude today's session until Tushare closing facts are expected to be ready."""
local_now = self.now_fn().astimezone(SHANGHAI)
if self.today == local_now.date() and local_now.time() < MARKET_DATA_READY_TIME:
return self.today - timedelta(days=1)
return self.today
def _build_target(self, target: _BuildTarget) -> BuildDateOutcome:
try:
with self.repository.advisory_lock(target.trade_date) as acquired:
if not acquired:
return BuildDateOutcome(
target.trade_date,
"locked",
None,
Decimal(0),
0,
0,
"build_locked",
"another sector radar build is running for this date",
)
return self._build_locked(target)
except Exception as exc:
error_type, message = self._safe_failure(exc)
return BuildDateOutcome(
target.trade_date,
"failed",
None,
Decimal(0),
0,
0,
error_type,
message,
)
def _build_locked(self, target: _BuildTarget) -> BuildDateOutcome:
started_at = self.now_fn()
publication: RadarPublication | None = None
publication_created = False
try:
self.repository.recover_running_publications(
target.trade_date,
finished_at=started_at,
)
publication_id = self._running_id(target.trade_date)
publication = RadarPublication(
publication_id=publication_id,
target_trade_date=target.trade_date,
status=PublicationStatus.RUNNING,
source_version=self.source_version,
universe_version="pending",
metric_versions=tuple(strategy.metric_version for strategy in self.strategies),
input_hash=None,
coverage=Decimal(0),
started_at=started_at,
)
self.repository.create_publication(publication)
publication_created = True
reusable = self._reusable_sources(target.retry_publication_id)
collected = self._collect(target.trade_date, publication_id, reusable)
input_hash = self._input_hash(collected.snapshots)
existing = self.repository.find_reusable_publication(target.trade_date, input_hash)
if existing is not None:
self.repository.discard_running_publication(publication_id)
publication_created = False
is_success = existing.status is PublicationStatus.SUCCESS
return BuildDateOutcome(
target.trade_date,
"unchanged" if is_success else "partial",
existing.publication_id,
existing.coverage,
0,
0,
None if is_success else "duplicate_input",
None
if is_success
else "input is unchanged from an existing partial publication",
)
publication = replace(
publication,
universe_version=self._universe_version(collected.membership_snapshots),
input_hash=input_hash,
)
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 membership_complete and coverage >= self.coverage_threshold
else PublicationStatus.PARTIAL
)
finished = RadarPublication(
publication_id=publication.publication_id,
target_trade_date=target.trade_date,
status=terminal,
source_version=publication.source_version,
universe_version=publication.universe_version,
metric_versions=publication.metric_versions,
input_hash=input_hash,
coverage=coverage,
started_at=started_at,
finished_at=self.now_fn(),
error_summary=(
None
if terminal is PublicationStatus.SUCCESS
else (
"membership_unknown"
if not membership_complete
else "coverage_below_threshold"
)
),
)
self.repository.finalize_publication(
finished,
memberships=collected.memberships,
stock_facts=collected.stock_facts,
daily_aggregates=(
DailyAggregateRecord(publication_id, aggregate) for aggregate in aggregates
),
rankings=(RankingRecord(publication_id, ranking) for ranking in rankings),
retry_source_groups=(
self._retry_source_groups(
collected.memberships,
collected.stock_facts,
)
if terminal is PublicationStatus.PARTIAL
else ()
),
)
return BuildDateOutcome(
target.trade_date,
"success" if terminal is PublicationStatus.SUCCESS else "partial",
publication_id,
coverage,
len(aggregates),
len(rankings),
)
except Exception as exc:
error_type, message = self._safe_failure(exc)
failed_id = (
publication.publication_id
if publication is not None
else self._failure_id(target.trade_date)
)
if publication is not None and publication_created:
self.repository.finish_publication(
RadarPublication(
publication_id=failed_id,
target_trade_date=target.trade_date,
status=PublicationStatus.FAILED,
source_version=publication.source_version,
universe_version=publication.universe_version,
metric_versions=publication.metric_versions,
input_hash=publication.input_hash,
coverage=Decimal(0),
started_at=started_at,
finished_at=self.now_fn(),
error_summary=f"{error_type}:{message}",
)
)
return BuildDateOutcome(
target.trade_date,
"failed",
failed_id,
Decimal(0),
0,
0,
error_type,
message,
)
def _reusable_sources(
self, publication_id: str | None
) -> dict[PublicationSourceGroup, tuple[SourceSnapshot, ...]]:
"""Load successful checkpoints while forcing incomplete coverage facts to refresh."""
if publication_id is None:
return {}
publication = self.repository.get_publication(publication_id)
if publication is None:
raise ValueError("retry publication does not exist")
grouped: defaultdict[PublicationSourceGroup, list[PublicationSourceRecord]] = defaultdict(
list
)
for record in self.repository.load_publication_sources(publication_id):
if not record.refresh_on_retry:
grouped[record.source_group].append(record)
result: dict[PublicationSourceGroup, tuple[SourceSnapshot, ...]] = {}
for group, records in grouped.items():
ordered = sorted(records, key=lambda item: item.source_order)
if [item.source_order for item in ordered] != list(range(len(ordered))):
raise ValueError("publication source checkpoint order is incomplete")
result[group] = tuple(item.snapshot for item in ordered)
return result
def _fetch_group[T](
self,
publication_id: str,
source_group: PublicationSourceGroup,
reusable: Mapping[PublicationSourceGroup, tuple[SourceSnapshot, ...]],
fetch: Callable[[], SourceResult[T]],
parser: Callable[[Mapping[str, SourceScalar]], T],
) -> SourceResult[T]:
"""Replay a completed group or fetch and checkpoint it immediately."""
snapshots = reusable.get(source_group)
if snapshots is None:
result = fetch()
else:
result = SourceResult(
snapshots=snapshots,
rows=tuple(parser(row) for snapshot in snapshots for row in snapshot.rows),
)
if not result.snapshots:
raise ValueError("source group must include at least one replay snapshot")
snapshot_ids = [snapshot.snapshot_id for snapshot in result.snapshots]
if len(snapshot_ids) != len(set(snapshot_ids)):
raise ValueError("source group contains duplicate snapshots")
self.repository.save_source_snapshots(result.snapshots)
self.repository.save_publication_sources(
PublicationSourceRecord(publication_id, source_group, order, snapshot)
for order, snapshot in enumerate(result.snapshots)
)
return result
def _collect(
self,
target: date,
publication_id: str,
reusable: Mapping[PublicationSourceGroup, tuple[SourceSnapshot, ...]],
) -> _CollectedInputs:
calendar = self._fetch_group(
publication_id,
PublicationSourceGroup.CALENDAR,
reusable,
lambda: self.source.fetch_trade_calendar(target, target),
TradeCalendarRow.from_mapping,
)
if target not in {row.cal_date for row in calendar.rows if row.is_open}:
raise ValueError("target date is not an open trading day")
concepts = self._fetch_group(
publication_id,
PublicationSourceGroup.CONCEPT_INDICES,
reusable,
lambda: self.source.fetch_sector_indices(target, SectorType.CONCEPT),
lambda row: SectorIndexRow.from_mapping(row, SectorType.CONCEPT),
)
industries = self._fetch_group(
publication_id,
PublicationSourceGroup.INDUSTRY_INDICES,
reusable,
lambda: self.source.fetch_sector_indices(target, SectorType.INDUSTRY),
lambda row: SectorIndexRow.from_mapping(row, SectorType.INDUSTRY),
)
indices = concepts.rows + industries.rows
sector_codes = tuple(row.sector_code for row in indices)
members = self._fetch_group(
publication_id,
PublicationSourceGroup.MEMBERS,
reusable,
lambda: self.source.fetch_sector_members(target, sector_codes),
SectorMemberRow.from_mapping,
)
stock_basics = self._fetch_group(
publication_id,
PublicationSourceGroup.STOCK_BASICS,
reusable,
self.source.fetch_stock_basics,
StockBasicRow.from_mapping,
)
suspensions = self._fetch_group(
publication_id,
PublicationSourceGroup.SUSPENSIONS,
reusable,
lambda: self.source.fetch_suspensions(target),
SuspendRow.from_mapping,
)
daily = self._fetch_group(
publication_id,
PublicationSourceGroup.DAILY,
reusable,
lambda: self.source.fetch_daily(target),
DailyRow.from_mapping,
)
moneyflow = self._fetch_group(
publication_id,
PublicationSourceGroup.MONEYFLOW_DC,
reusable,
lambda: self.source.fetch_moneyflow_dc(target),
MoneyflowDcRow.from_mapping,
)
memberships = normalize_memberships(indices, members)
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,
stock_basics=stock_basics,
suspensions=suspensions,
daily=daily,
moneyflow=moneyflow,
)
snapshots = (
calendar.snapshots
+ concepts.snapshots
+ industries.snapshots
+ members.snapshots
+ stock_basics.snapshots
+ suspensions.snapshots
+ daily.snapshots
+ moneyflow.snapshots
)
return _CollectedInputs(
target_trade_date=target,
snapshots=snapshots,
membership_snapshots=members.snapshots,
memberships=memberships,
stock_facts=stock_facts,
)
def _aggregate(self, inputs: _CollectedInputs) -> tuple[SectorDailyAggregate, ...]:
facts = tuple(
StockDailyFact(
trade_date=item.trade_date,
ts_code=item.ts_code,
status=item.status,
turnover_yuan=item.turnover_yuan,
net_amount_yuan=item.net_amount_yuan,
)
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:
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(
trade_date=inputs.target_trade_date,
sector_type=sector_type,
sector_code=sector_code,
sector_name=sector_name,
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 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")
return aggregates
def _rank(
self, target: date, aggregates: Sequence[SectorDailyAggregate]
) -> tuple[RankedMetric, ...]:
history = tuple(self.repository.load_daily_aggregate_history(target, limit_dates=9))
observations: list[MetricObservation] = []
for current in aggregates:
sector_history = tuple(
item
for item in history
if (item.sector_type, item.sector_code)
== (current.sector_type, current.sector_code)
) + (current,)
observations.extend(
strategy.evaluate(sector_history, target) for strategy in self.strategies
)
current_rankings = rank_metric_observations(observations)
previous = self.repository.load_previous_rankings(target, limit_dates=5)
history_by_days = {days: rankings for days, (_, rankings) in enumerate(previous, start=1)}
return with_rank_changes(current_rankings, history_by_days)
@staticmethod
def _coverage(stock_facts: Sequence[StockFactRecord]) -> Decimal:
expected_statuses = {
"available",
"missing",
"missing_daily",
"missing_moneyflow",
"null_daily_amount",
"null_moneyflow",
"low_liquidity",
}
expected = sum(item.status.value in expected_statuses for item in stock_facts)
covered_statuses = {StockFactStatus.AVAILABLE, StockFactStatus.LOW_LIQUIDITY}
covered = sum(item.status in covered_statuses for item in stock_facts)
return Decimal(covered) / Decimal(expected) if expected else Decimal(0)
@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,
StockFactStatus.MISSING_DAILY,
StockFactStatus.NULL_DAILY_AMOUNT,
}:
groups.append(PublicationSourceGroup.DAILY)
if statuses & {
StockFactStatus.MISSING,
StockFactStatus.MISSING_MONEYFLOW,
StockFactStatus.NULL_MONEYFLOW,
}:
groups.append(PublicationSourceGroup.MONEYFLOW_DC)
return tuple(groups)
def _input_hash(self, snapshots: Sequence[SourceSnapshot]) -> str:
payload = json.dumps(
{
"snapshot_ids": sorted(snapshot.snapshot_id for snapshot in snapshots),
"metric_versions": sorted(strategy.metric_version for strategy in self.strategies),
"normalizer": "zhixing_stock_fact_v1",
},
sort_keys=True,
separators=(",", ":"),
)
return hashlib.sha256(payload.encode()).hexdigest()
@staticmethod
def _universe_version(snapshots: Sequence[SourceSnapshot]) -> str:
payload = "\n".join(sorted(snapshot.snapshot_id for snapshot in snapshots))
return f"eastmoney-dc-{hashlib.sha256(payload.encode()).hexdigest()[:32]}"
@staticmethod
def _failure_id(target: date) -> str:
return f"radar-{target:%Y%m%d}-failed-{uuid4().hex[:24]}"
@staticmethod
def _running_id(target: date) -> str:
return f"radar-{target:%Y%m%d}-running-{uuid4().hex[:23]}"
@staticmethod
def _safe_failure(error: BaseException) -> tuple[str, str]:
if isinstance(error, ValueError):
return type(error).__name__, "input or source contract validation failed"
return type(error).__name__, "sector radar build failed"
@dataclass(frozen=True, slots=True)
class _CollectedInputs:
target_trade_date: date
snapshots: tuple[SourceSnapshot, ...]
membership_snapshots: tuple[SourceSnapshot, ...]
memberships: tuple[MembershipRecord, ...]
stock_facts: tuple[StockFactRecord, ...]
@dataclass(frozen=True, slots=True)
class _BuildTarget:
trade_date: date
retry_publication_id: str | None = None
@@ -0,0 +1,223 @@
"""Stable read model for persisted sector radar publications."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from enum import StrEnum
from typing import Literal
from ..domain.metrics import (
AmountNetStrategy,
RatioTurnoverStrategy,
SwingEqualThreeToTenStrategy,
)
from ..domain.models import (
MetricKind,
MetricUnit,
RadarPublication,
RankedMetric,
RankSide,
SectorType,
)
from ..domain.persistence import SectorRadarRepository
from ..domain.ranking import select_percentile_side, select_rank_change_side
ReadStatus = Literal["success", "no_data"]
class RadarView(StrEnum):
"""Supported ranking projections at the HTTP boundary."""
AMOUNT = "amount"
RATIO = "ratio"
SWING = "swing"
RANK_CHANGE = "rank_change"
@dataclass(frozen=True, slots=True)
class RadarMetricDefinition:
"""Public definition of one explicitly independent metric implementation."""
metric_kind: MetricKind
metric_version: str
label: str
unit: MetricUnit
implementation_kind: Literal["independent"] = "independent"
disclaimer: str = "知行独立实现,非 OneChartLab 原站公式"
@dataclass(frozen=True, slots=True)
class RadarQuery:
"""Validated application query for one ranking page."""
trade_date: date | None = None
sector_type: SectorType = SectorType.CONCEPT
view: RadarView = RadarView.AMOUNT
rank_change_metric: MetricKind = MetricKind.AMOUNT
rank_change_days: int = 1
side: RankSide = RankSide.ALL
search: str | None = None
page: int = 1
page_size: int = 20
def __post_init__(self) -> None:
"""Reject invalid pagination and rank-history offsets outside HTTP usage."""
if not 1 <= self.rank_change_days <= 5:
raise ValueError("rank_change_days must be between 1 and 5")
if self.page < 1:
raise ValueError("page must be positive")
if not 1 <= self.page_size <= 100:
raise ValueError("page_size must be between 1 and 100")
if self.search is not None and len(self.search) > 100:
raise ValueError("search must not exceed 100 characters")
@dataclass(frozen=True, slots=True)
class RadarDateIndex:
"""Available successful dates plus the newest attempt and strict last-good."""
available_dates: tuple[date, ...]
current_attempt: RadarPublication | None
last_good: RadarPublication | None
@property
def status(self) -> ReadStatus:
"""Return no_data until at least one successful publication exists."""
return "success" if self.last_good is not None else "no_data"
@dataclass(frozen=True, slots=True)
class RankingPage:
"""One filtered page without losing publication or metric provenance."""
status: ReadStatus
query: RadarQuery
publication: RadarPublication | None
definition: RadarMetricDefinition
rows: tuple[RankedMetric, ...]
total: int
_METRIC_DEFINITIONS = {
MetricKind.AMOUNT: RadarMetricDefinition(
metric_kind=MetricKind.AMOUNT,
metric_version=AmountNetStrategy.metric_version,
label="主力净流入(知行独立实现)",
unit=MetricUnit.CNY_100M,
),
MetricKind.RATIO: RadarMetricDefinition(
metric_kind=MetricKind.RATIO,
metric_version=RatioTurnoverStrategy.metric_version,
label="主力净流入/成交额(知行独立实现)",
unit=MetricUnit.RATIO,
),
MetricKind.SWING: RadarMetricDefinition(
metric_kind=MetricKind.SWING,
metric_version=SwingEqualThreeToTenStrategy.metric_version,
label="3—10 日等权资金率(知行独立实现)",
unit=MetricUnit.RATIO,
),
}
class ReadSectorRadar:
"""Hide last-good selection, ranking filters, search, and pagination."""
def __init__(self, repository: SectorRadarRepository) -> None:
self.repository = repository
def list_dates(self) -> RadarDateIndex:
"""Return successful dates without promoting partial or failed attempts."""
return RadarDateIndex(
available_dates=tuple(self.repository.list_successful_dates()),
current_attempt=self.repository.get_latest_publication(),
last_good=self.repository.get_last_good_publication(),
)
def query(self, query: RadarQuery) -> RankingPage:
"""Return one deterministic page for ordinary or rank-change views."""
metric_kind = (
query.rank_change_metric
if query.view is RadarView.RANK_CHANGE
else MetricKind(query.view.value)
)
definition = _METRIC_DEFINITIONS[metric_kind]
publication = (
self.repository.get_successful_publication(query.trade_date)
if query.trade_date is not None
else self.repository.get_last_good_publication()
)
if publication is None:
return RankingPage("no_data", query, None, definition, (), 0)
metric_rows = tuple(
row
for row in self.repository.load_rankings(publication.publication_id)
if row.observation.sector_type is query.sector_type
and row.observation.metric_kind is metric_kind
and row.observation.metric_version == definition.metric_version
)
if query.view is RadarView.RANK_CHANGE:
if query.side is RankSide.ALL:
selected = tuple(
sorted(
metric_rows,
key=lambda row: (
row.rank_change(query.rank_change_days) is None,
-(row.rank_change(query.rank_change_days) or 0),
row.observation.sector_code,
),
)
)
else:
selected = select_rank_change_side(
metric_rows,
days=query.rank_change_days,
side=query.side,
)
else:
selected = select_percentile_side(metric_rows, query.side)
if query.side is not RankSide.BOTTOM:
selected = tuple(
sorted(
selected,
key=lambda row: (
row.rank_position is None,
row.rank_position or 0,
row.observation.sector_code,
),
)
)
search = query.search.strip().casefold() if query.search else ""
searched = tuple(
row
for row in selected
if not search
or search in row.observation.sector_code.casefold()
or search in row.observation.sector_name.casefold()
)
start = (query.page - 1) * query.page_size
return RankingPage(
status="success",
query=query,
publication=publication,
definition=definition,
rows=searched[start : start + query.page_size],
total=len(searched),
)
__all__ = [
"RadarDateIndex",
"RadarMetricDefinition",
"RadarQuery",
"RadarView",
"RankingPage",
"ReadSectorRadar",
]
@@ -0,0 +1 @@
"""Storage-independent sector radar models and calculation rules."""
@@ -0,0 +1,104 @@
"""Point-in-time stock fact aggregation for sector radar metrics."""
from __future__ import annotations
from collections.abc import Iterable
from decimal import Decimal
from .models import (
MembershipStatus,
SectorDailyAggregate,
SectorMembershipSnapshot,
StockDailyFact,
StockFactStatus,
)
def aggregate_sector_snapshot(
snapshot: SectorMembershipSnapshot,
stock_facts: Iterable[StockDailyFact],
) -> SectorDailyAggregate:
"""Aggregate only the members recorded in one dated membership snapshot.
Unknown membership returns an unavailable aggregate and deliberately
ignores any supplied stock facts. For known membership, suspended and
lifecycle-invalid members are excluded from the expected moneyflow
denominator; missing facts remain expected and reduce coverage.
Args:
snapshot: Dated sector identity and point-in-time member codes.
stock_facts: Normalized facts that may contain records outside the sector.
Returns:
A yuan-denominated aggregate with explicit membership and moneyflow coverage.
Raises:
ValueError: If member facts have a date mismatch or duplicate stock code.
"""
if snapshot.status is MembershipStatus.UNKNOWN:
return SectorDailyAggregate(
trade_date=snapshot.trade_date,
sector_type=snapshot.sector_type,
sector_code=snapshot.sector_code,
sector_name=snapshot.sector_name,
member_count=0,
valid_sample_count=0,
net_amount_yuan=None,
turnover_yuan=None,
membership_coverage=Decimal(0),
moneyflow_coverage=Decimal(0),
)
members = set(snapshot.member_codes)
facts_by_code: dict[str, StockDailyFact] = {}
for fact in stock_facts:
if fact.ts_code not in members:
continue
if fact.trade_date != snapshot.trade_date:
raise ValueError("member stock facts must match the snapshot trade_date")
if fact.ts_code in facts_by_code:
raise ValueError("member stock facts must have unique ts_code values")
facts_by_code[fact.ts_code] = fact
net_amount_total = Decimal(0)
turnover_total = Decimal(0)
valid_count = 0
expected_count = 0
for member_code in snapshot.member_codes:
fact = facts_by_code.get(member_code)
if fact is None or fact.status in {
StockFactStatus.MISSING,
StockFactStatus.MISSING_DAILY,
StockFactStatus.MISSING_MONEYFLOW,
StockFactStatus.NULL_DAILY_AMOUNT,
StockFactStatus.NULL_MONEYFLOW,
StockFactStatus.LOW_LIQUIDITY,
}:
expected_count += 1
elif fact.status is StockFactStatus.AVAILABLE:
expected_count += 1
net_amount = fact.net_amount_yuan
turnover = fact.turnover_yuan
if net_amount is None or turnover is None:
raise ValueError("available stock facts require both amounts")
net_amount_total += net_amount
turnover_total += turnover
valid_count += 1
moneyflow_coverage = (
Decimal(valid_count) / Decimal(expected_count) if expected_count else Decimal(1)
)
return SectorDailyAggregate(
trade_date=snapshot.trade_date,
sector_type=snapshot.sector_type,
sector_code=snapshot.sector_code,
sector_name=snapshot.sector_name,
member_count=len(snapshot.member_codes),
valid_sample_count=valid_count,
net_amount_yuan=net_amount_total if valid_count else None,
turnover_yuan=turnover_total if valid_count else None,
membership_coverage=Decimal(1),
moneyflow_coverage=moneyflow_coverage,
)
@@ -0,0 +1,204 @@
"""Transparent, versioned metric strategies for the independent radar."""
from __future__ import annotations
from collections.abc import Iterable
from datetime import date
from decimal import Decimal
from typing import Protocol
from .models import (
MetricKind,
MetricObservation,
MetricQuality,
MetricUnit,
SectorDailyAggregate,
)
class MetricStrategy(Protocol):
"""Calculate one named metric from a sector's point-in-time daily history."""
metric_kind: MetricKind
metric_version: str
unit: MetricUnit
def evaluate(
self,
history: Iterable[SectorDailyAggregate],
target_trade_date: date,
) -> MetricObservation:
"""Return the target date observation without inventing missing inputs."""
...
def _target_aggregate(
history: Iterable[SectorDailyAggregate], target_trade_date: date
) -> SectorDailyAggregate:
matches = tuple(row for row in history if row.trade_date == target_trade_date)
if len(matches) != 1:
raise ValueError("history must contain exactly one target-date aggregate")
return matches[0]
def _quality(row: SectorDailyAggregate) -> MetricQuality:
if row.valid_sample_count < 5 or row.membership_coverage < 1 or row.moneyflow_coverage < 1:
return MetricQuality.AVAILABLE_LIMITED_SAMPLE
return MetricQuality.AVAILABLE
def _observation(
row: SectorDailyAggregate,
*,
metric_kind: MetricKind,
metric_version: str,
unit: MetricUnit,
value: Decimal | None,
quality: MetricQuality | None = None,
) -> MetricObservation:
return MetricObservation(
trade_date=row.trade_date,
sector_type=row.sector_type,
sector_code=row.sector_code,
sector_name=row.sector_name,
metric_kind=metric_kind,
metric_version=metric_version,
implementation_kind="independent",
unit=unit,
value=value,
quality=(
MetricQuality.UNAVAILABLE
if value is None
else quality
if quality is not None
else _quality(row)
),
member_count=row.member_count,
valid_sample_count=row.valid_sample_count,
membership_coverage=row.membership_coverage,
moneyflow_coverage=row.moneyflow_coverage,
)
class AmountNetStrategy:
"""Aggregate main net amount and expose it in hundred-million yuan."""
metric_kind = MetricKind.AMOUNT
metric_version = "zhixing_amount_net_bn_v1"
unit = MetricUnit.CNY_100M
def evaluate(
self,
history: Iterable[SectorDailyAggregate],
target_trade_date: date,
) -> MetricObservation:
"""Return the target net amount; missing moneyflow remains unavailable."""
row = _target_aggregate(history, target_trade_date)
value = None if row.net_amount_yuan is None else row.net_amount_yuan / Decimal("100000000")
return _observation(
row,
metric_kind=self.metric_kind,
metric_version=self.metric_version,
unit=self.unit,
value=value,
)
class RatioTurnoverStrategy:
"""Divide aggregated main net amount by aggregated daily turnover."""
metric_kind = MetricKind.RATIO
metric_version = "zhixing_ratio_turnover_v1"
unit = MetricUnit.RATIO
def evaluate(
self,
history: Iterable[SectorDailyAggregate],
target_trade_date: date,
) -> MetricObservation:
"""Return a ratio only when numerator and positive denominator exist."""
row = _target_aggregate(history, target_trade_date)
value = None
if (
row.net_amount_yuan is not None
and row.turnover_yuan is not None
and row.turnover_yuan > 0
):
value = row.net_amount_yuan / row.turnover_yuan
return _observation(
row,
metric_kind=self.metric_kind,
metric_version=self.metric_version,
unit=self.unit,
value=value,
)
class SwingEqualThreeToTenStrategy:
"""Average transparent 3-to-10-day aggregate turnover ratios equally.
This strategy is deliberately named as a Zhixing implementation. It does
not reproduce or imply OneChartLab's unpublished window weights or score.
"""
metric_kind = MetricKind.SWING
metric_version = "zhixing_swing_equal_3_10_v1"
unit = MetricUnit.RATIO
def evaluate(
self,
history: Iterable[SectorDailyAggregate],
target_trade_date: date,
) -> MetricObservation:
"""Calculate eight complete trading-day windows ending at the target."""
rows = tuple(sorted(history, key=lambda row: row.trade_date))
target = _target_aggregate(rows, target_trade_date)
eligible = tuple(row for row in rows if row.trade_date <= target_trade_date)
if any(
(row.sector_type, row.sector_code) != (target.sector_type, target.sector_code)
for row in eligible
):
raise ValueError("history must contain exactly one sector identity")
if len({row.trade_date for row in eligible}) != len(eligible):
raise ValueError("history must not contain duplicate trade dates")
value: Decimal | None = None
quality: MetricQuality | None = None
if len(eligible) >= 10:
latest = eligible[-10:]
window_ratios: list[Decimal] = []
for window_size in range(3, 11):
window = latest[-window_size:]
net_amount = Decimal(0)
turnover = Decimal(0)
for row in window:
if row.net_amount_yuan is None or row.turnover_yuan is None:
break
net_amount += row.net_amount_yuan
turnover += row.turnover_yuan
else:
if turnover <= 0:
break
window_ratios.append(net_amount / turnover)
continue
break
if len(window_ratios) == 8:
value = sum(window_ratios, start=Decimal(0)) / Decimal(8)
quality = (
MetricQuality.AVAILABLE_LIMITED_SAMPLE
if any(_quality(row) is not MetricQuality.AVAILABLE for row in latest)
else MetricQuality.AVAILABLE
)
return _observation(
target,
metric_kind=self.metric_kind,
metric_version=self.metric_version,
unit=self.unit,
value=value,
quality=quality,
)
@@ -0,0 +1,319 @@
"""Stable domain values for independently produced sector radar metrics."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime
from decimal import Decimal
from enum import StrEnum
from typing import Literal
class SectorType(StrEnum):
"""Independent ranking pools supported by the first radar release."""
CONCEPT = "concept"
INDUSTRY = "industry"
class MembershipStatus(StrEnum):
"""Availability of a point-in-time sector membership snapshot."""
AVAILABLE = "available"
UNKNOWN = "membership_unknown"
class StockFactStatus(StrEnum):
"""Why one member does or does not contribute to a daily aggregate."""
AVAILABLE = "available"
SUSPENDED = "suspended"
MISSING = "missing"
MISSING_DAILY = "missing_daily"
MISSING_MONEYFLOW = "missing_moneyflow"
NULL_DAILY_AMOUNT = "null_daily_amount"
NULL_MONEYFLOW = "null_moneyflow"
LIFECYCLE_INVALID = "lifecycle_invalid"
LOW_LIQUIDITY = "low_liquidity"
class PublicationStatus(StrEnum):
"""Immutable build states retained for audit and last-good selection."""
RUNNING = "running"
SUCCESS = "success"
PARTIAL = "partial"
FAILED = "failed"
class MetricKind(StrEnum):
"""User-facing metric families without borrowing private score names."""
AMOUNT = "amount"
RATIO = "ratio"
SWING = "swing"
class MetricQuality(StrEnum):
"""Whether a metric is usable and whether its sample needs a warning."""
AVAILABLE = "available"
AVAILABLE_LIMITED_SAMPLE = "available_limited_sample"
UNAVAILABLE = "unavailable"
class MetricUnit(StrEnum):
"""Units exposed by independent metric strategies."""
CNY_100M = "CNY_100M"
RATIO = "ratio"
class RankSide(StrEnum):
"""Ordinary percentile views exposed by the ranking read model."""
TOP = "top"
BOTTOM = "bottom"
ALL = "all"
def _validate_finite_decimal(value: Decimal | None, field_name: str) -> None:
"""Reject non-finite domain values while preserving missing values."""
if value is not None and not value.is_finite():
raise ValueError(f"{field_name} must be finite or None")
def _validate_coverage(value: Decimal, field_name: str) -> None:
"""Require a finite fraction in the inclusive zero-to-one range."""
_validate_finite_decimal(value, field_name)
if value < 0 or value > 1:
raise ValueError(f"{field_name} must be between 0 and 1")
@dataclass(frozen=True, slots=True)
class SectorMembershipSnapshot:
"""One sector's membership as observed for exactly one trade date.
``UNKNOWN`` is an explicit fact: callers must not substitute a current
member list when the historical snapshot is unavailable.
"""
trade_date: date
sector_type: SectorType
sector_code: str
sector_name: str
member_codes: tuple[str, ...]
status: MembershipStatus
source_version: str
def __post_init__(self) -> None:
"""Validate identity, deterministic membership, and unknown semantics."""
if not self.sector_code.strip():
raise ValueError("sector_code must not be empty")
if not self.sector_name.strip():
raise ValueError("sector_name must not be empty")
if not self.source_version.strip():
raise ValueError("source_version must not be empty")
if any(not code.strip() for code in self.member_codes):
raise ValueError("member_codes must not contain empty values")
if len(self.member_codes) != len(set(self.member_codes)):
raise ValueError("member_codes must be unique")
if self.status is MembershipStatus.UNKNOWN and self.member_codes:
raise ValueError("unknown membership must not expose member_codes")
@dataclass(frozen=True, slots=True)
class StockDailyFact:
"""Normalized daily turnover and moneyflow for one member.
Amounts are expressed in yuan. Available facts require both source
values, including an observed zero. Non-available statuses cannot carry
amounts because doing so would blur missing, suspended, and lifecycle
semantics at the metric boundary.
"""
trade_date: date
ts_code: str
status: StockFactStatus
turnover_yuan: Decimal | None = None
net_amount_yuan: Decimal | None = None
def __post_init__(self) -> None:
"""Reject incomplete available facts and hidden non-finite values."""
if not self.ts_code.strip():
raise ValueError("ts_code must not be empty")
_validate_finite_decimal(self.turnover_yuan, "turnover_yuan")
_validate_finite_decimal(self.net_amount_yuan, "net_amount_yuan")
if self.status is StockFactStatus.AVAILABLE:
if self.turnover_yuan is None or self.net_amount_yuan is None:
raise ValueError("available stock facts require both amounts")
if self.turnover_yuan < 0:
raise ValueError("turnover_yuan must not be negative")
elif self.turnover_yuan is not None or self.net_amount_yuan is not None:
raise ValueError("non-available stock facts must not expose amounts")
@dataclass(frozen=True, slots=True)
class RadarPublication:
"""Traceable identity and lifecycle of one immutable radar build revision."""
publication_id: str
target_trade_date: date
status: PublicationStatus
source_version: str
universe_version: str
metric_versions: tuple[str, ...]
input_hash: str | None
coverage: Decimal
started_at: datetime
finished_at: datetime | None = None
error_summary: str | None = None
def __post_init__(self) -> None:
"""Keep running and terminal lifecycle timestamps internally consistent."""
if not self.publication_id.strip():
raise ValueError("publication_id must not be empty")
if not self.source_version.strip() or not self.universe_version.strip():
raise ValueError("publication source versions must not be empty")
if not self.metric_versions or any(not value.strip() for value in self.metric_versions):
raise ValueError("metric_versions must contain named strategies")
if len(self.metric_versions) != len(set(self.metric_versions)):
raise ValueError("metric_versions must be unique")
_validate_coverage(self.coverage, "coverage")
if self.started_at.tzinfo is None:
raise ValueError("started_at must be timezone-aware")
is_running = self.status is PublicationStatus.RUNNING
if is_running != (self.finished_at is None):
raise ValueError("finished_at must be absent only while publication is running")
if self.finished_at is not None:
if self.finished_at.tzinfo is None:
raise ValueError("finished_at must be timezone-aware")
if self.finished_at < self.started_at:
raise ValueError("finished_at must not precede started_at")
if self.status is PublicationStatus.SUCCESS and self.input_hash is None:
raise ValueError("successful publication requires input_hash")
if self.input_hash is not None and (
len(self.input_hash) != 64
or any(character not in "0123456789abcdef" for character in self.input_hash)
):
raise ValueError("input_hash must be a lowercase SHA-256 hex digest")
@dataclass(frozen=True, slots=True)
class SectorDailyAggregate:
"""One sector's point-in-time daily facts after source normalization.
Amounts use yuan so strategies cannot accidentally mix Tushare's
``moneyflow_dc.net_amount`` (ten-thousand yuan) with ``daily.amount``
(thousand yuan). ``None`` means missing source data; zero remains an
observed value.
"""
trade_date: date
sector_type: SectorType
sector_code: str
sector_name: str
member_count: int
valid_sample_count: int
net_amount_yuan: Decimal | None
turnover_yuan: Decimal | None
membership_coverage: Decimal
moneyflow_coverage: Decimal
def __post_init__(self) -> None:
"""Validate counts, coverage, and finite normalized values."""
if not self.sector_code.strip():
raise ValueError("sector_code must not be empty")
if not self.sector_name.strip():
raise ValueError("sector_name must not be empty")
if self.member_count < 0:
raise ValueError("member_count must not be negative")
if not 0 <= self.valid_sample_count <= self.member_count:
raise ValueError("valid_sample_count must be within member_count")
_validate_finite_decimal(self.net_amount_yuan, "net_amount_yuan")
_validate_finite_decimal(self.turnover_yuan, "turnover_yuan")
_validate_coverage(self.membership_coverage, "membership_coverage")
_validate_coverage(self.moneyflow_coverage, "moneyflow_coverage")
@dataclass(frozen=True, slots=True)
class MetricObservation:
"""One versioned independent metric value ready for cross-sectional ranking."""
trade_date: date
sector_type: SectorType
sector_code: str
sector_name: str
metric_kind: MetricKind
metric_version: str
implementation_kind: Literal["independent"]
unit: MetricUnit
value: Decimal | None
quality: MetricQuality
member_count: int
valid_sample_count: int
membership_coverage: Decimal
moneyflow_coverage: Decimal
def __post_init__(self) -> None:
"""Keep unavailable and finite-value states internally consistent."""
_validate_finite_decimal(self.value, "value")
if self.value is None and self.quality is not MetricQuality.UNAVAILABLE:
raise ValueError("a missing metric value must be unavailable")
if self.value is not None and self.quality is MetricQuality.UNAVAILABLE:
raise ValueError("an unavailable metric must not expose a value")
@dataclass(frozen=True, slots=True)
class RankChange:
"""One previous-publication rank delta using past minus current rank."""
days: int
value: int | None
def __post_init__(self) -> None:
"""Limit the public comparison window to one through five days."""
if not 1 <= self.days <= 5:
raise ValueError("rank change days must be between 1 and 5")
@dataclass(frozen=True, slots=True)
class RankedMetric:
"""A metric observation with its position inside one independent pool."""
observation: MetricObservation
rank_position: int | None
rank_percentile: Decimal | None
rank_changes: tuple[RankChange, ...] = ()
def __post_init__(self) -> None:
"""Require rank position and percentile to be present or absent together."""
if (self.rank_position is None) != (self.rank_percentile is None):
raise ValueError("rank_position and rank_percentile must be paired")
if self.rank_position is not None and self.rank_position < 1:
raise ValueError("rank_position must be positive")
_validate_finite_decimal(self.rank_percentile, "rank_percentile")
if self.rank_percentile is not None and not 0 < self.rank_percentile <= 100:
raise ValueError("rank_percentile must be within (0, 100]")
days = [change.days for change in self.rank_changes]
if len(days) != len(set(days)):
raise ValueError("rank change days must be unique")
def rank_change(self, days: int) -> int | None:
"""Return one configured rank delta, or ``None`` when history is absent."""
if not 1 <= days <= 5:
raise ValueError("rank change days must be between 1 and 5")
return next(
(change.value for change in self.rank_changes if change.days == days),
None,
)
@@ -0,0 +1,230 @@
"""Normalize typed Tushare rows into point-in-time persisted radar facts."""
from __future__ import annotations
import hashlib
import json
from collections.abc import Callable, Sequence
from datetime import date
from .models import MembershipStatus, StockFactStatus
from .persistence import MembershipRecord, StockFactRecord
from .source import (
DailyRow,
MoneyflowDcRow,
SectorIndexRow,
SectorMemberRow,
SourceContractError,
SourceResult,
StockBasicRow,
SuspendRow,
)
def normalize_memberships(
indices: Sequence[SectorIndexRow],
members: SourceResult[SectorMemberRow],
) -> tuple[MembershipRecord, ...]:
"""Attach each dated member to its sector identity and raw source partition.
Args:
indices: The complete concept or industry universe for one date.
members: Validated membership rows plus all raw request snapshots.
Returns:
Deterministically ordered, source-traceable membership records.
Raises:
SourceContractError: If a member references an unknown sector or lacks a snapshot.
"""
index_by_code = {row.sector_code: row for row in indices}
if len(index_by_code) != len(indices):
raise SourceContractError("sector indices contain duplicate codes")
partition_ids = {
snapshot.partition_key: snapshot.snapshot_id
for snapshot in members.snapshots
if snapshot.partition_key not in {None, "all"}
}
all_snapshot_id = next(
(
snapshot.snapshot_id
for snapshot in members.snapshots
if snapshot.partition_key in {None, "all"}
),
None,
)
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")
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("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,
)
)
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(
*,
target_trade_date: date,
candidate_codes: Sequence[str],
stock_basics: SourceResult[StockBasicRow],
suspensions: SourceResult[SuspendRow],
daily: SourceResult[DailyRow],
moneyflow: SourceResult[MoneyflowDcRow],
) -> tuple[StockFactRecord, ...]:
"""Build normalized yuan facts without collapsing missing states into zero.
Args:
target_trade_date: Date whose point-in-time lifecycle is evaluated.
candidate_codes: Union of stocks in that date's sector memberships.
stock_basics: All explicit Tushare listing-status partitions.
suspensions: Same-date suspend/resume events.
daily: Same-date stock turnover rows in source units.
moneyflow: Same-date DC main-moneyflow rows in source units.
Returns:
One deterministic fact per candidate code under a content-derived revision.
"""
if len(candidate_codes) != len(set(candidate_codes)):
raise ValueError("candidate_codes must be unique")
basic_by_code = _unique_index(stock_basics.rows, lambda row: row.ts_code, "stock_basic")
daily_by_code = _unique_index(daily.rows, lambda row: row.ts_code, "daily")
moneyflow_by_code = _unique_index(moneyflow.rows, lambda row: row.ts_code, "moneyflow_dc")
suspended_codes = {
row.ts_code
for row in suspensions.rows
if row.trade_date == target_trade_date and _is_suspend_event(row.suspend_type)
}
source_snapshot_ids = tuple(
sorted(
{
snapshot.snapshot_id
for result in (stock_basics, suspensions, daily, moneyflow)
for snapshot in result.snapshots
}
)
)
revision_payload = json.dumps(
{
"target_trade_date": target_trade_date.isoformat(),
"source_snapshot_ids": source_snapshot_ids,
"normalizer": "zhixing_stock_fact_v1",
},
sort_keys=True,
separators=(",", ":"),
)
fact_revision = hashlib.sha256(revision_payload.encode()).hexdigest()
records: list[StockFactRecord] = []
for ts_code in sorted(candidate_codes):
basic = basic_by_code.get(ts_code)
daily_row = daily_by_code.get(ts_code)
moneyflow_row = moneyflow_by_code.get(ts_code)
status = StockFactStatus.AVAILABLE
turnover_yuan = None
net_amount_yuan = None
if basic is None or not _is_lifecycle_candidate(basic, target_trade_date):
status = StockFactStatus.LIFECYCLE_INVALID
elif ts_code in suspended_codes and daily_row is None:
status = StockFactStatus.SUSPENDED
elif daily_row is None:
status = StockFactStatus.MISSING_DAILY
elif daily_row.amount_thousand_yuan is None:
status = StockFactStatus.NULL_DAILY_AMOUNT
elif moneyflow_row is None:
status = StockFactStatus.MISSING_MONEYFLOW
elif moneyflow_row.net_amount_ten_thousand_yuan is None:
status = StockFactStatus.NULL_MONEYFLOW
elif daily_row.turnover_yuan == 0:
status = StockFactStatus.LOW_LIQUIDITY
else:
turnover_yuan = daily_row.turnover_yuan
net_amount_yuan = moneyflow_row.net_amount_yuan
records.append(
StockFactRecord(
fact_revision=fact_revision,
source_snapshot_ids=source_snapshot_ids,
trade_date=target_trade_date,
ts_code=ts_code,
status=status,
turnover_yuan=turnover_yuan,
net_amount_yuan=net_amount_yuan,
)
)
return tuple(records)
def _is_lifecycle_candidate(stock: StockBasicRow, target: date) -> bool:
if not stock.ts_code.endswith((".SH", ".SZ")):
return False
if stock.symbol.startswith(("200", "900")):
return False
if "北交" in stock.market or "B股" in stock.market.upper():
return False
if stock.list_date is None or stock.list_date > target:
return False
return stock.delist_date is None or target <= stock.delist_date
def _is_suspend_event(value: str) -> bool:
normalized = value.strip().casefold()
return normalized in {"s", "suspend", "停牌"} or (
"停牌" in normalized and "复牌" not in normalized
)
def _unique_index[T, K](
rows: Sequence[T],
key: Callable[[T], K],
source_name: str,
) -> dict[K, T]:
result: dict[K, T] = {}
for row in rows:
item_key = key(row)
if item_key in result:
raise SourceContractError(f"{source_name} contains duplicate business keys")
result[item_key] = row
return result
@@ -0,0 +1,251 @@
"""Persistence records and repository port for replayable radar revisions."""
from __future__ import annotations
from collections.abc import Iterable, Sequence
from contextlib import AbstractContextManager
from dataclasses import dataclass
from datetime import date, datetime
from decimal import Decimal
from enum import StrEnum
from typing import Protocol
from .models import (
MembershipStatus,
RadarPublication,
RankedMetric,
SectorDailyAggregate,
SectorType,
StockFactStatus,
)
from .source import SourceSnapshot
def _validate_digest(value: str, field_name: str) -> None:
if len(value) != 64 or any(character not in "0123456789abcdef" for character in value):
raise ValueError(f"{field_name} must be a lowercase SHA-256 digest")
def _validate_optional_decimal(value: Decimal | None, field_name: str) -> None:
if value is not None and not value.is_finite():
raise ValueError(f"{field_name} must be finite or None")
@dataclass(frozen=True, slots=True)
class MembershipRecord:
"""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 | None
stock_name: str | None
status: MembershipStatus = MembershipStatus.AVAILABLE
def __post_init__(self) -> None:
"""Validate available and unknown membership null semantics."""
_validate_digest(self.source_snapshot_id, "source_snapshot_id")
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)
class StockFactRecord:
"""One normalized stock fact revision with all contributing raw snapshots."""
fact_revision: str
source_snapshot_ids: tuple[str, ...]
trade_date: date
ts_code: str
status: StockFactStatus
turnover_yuan: Decimal | None = None
net_amount_yuan: Decimal | None = None
def __post_init__(self) -> None:
"""Preserve source traceability and stock fact null semantics."""
_validate_digest(self.fact_revision, "fact_revision")
if not self.source_snapshot_ids or len(self.source_snapshot_ids) != len(
set(self.source_snapshot_ids)
):
raise ValueError("source_snapshot_ids must be non-empty and unique")
for value in self.source_snapshot_ids:
_validate_digest(value, "source_snapshot_id")
if not self.ts_code.strip():
raise ValueError("ts_code must not be empty")
_validate_optional_decimal(self.turnover_yuan, "turnover_yuan")
_validate_optional_decimal(self.net_amount_yuan, "net_amount_yuan")
if self.status is StockFactStatus.AVAILABLE:
if self.turnover_yuan is None or self.net_amount_yuan is None:
raise ValueError("available stock facts require both amounts")
if self.turnover_yuan < 0:
raise ValueError("turnover_yuan must not be negative")
elif self.turnover_yuan is not None or self.net_amount_yuan is not None:
raise ValueError("non-available stock facts must not expose amounts")
@dataclass(frozen=True, slots=True)
class RankingRecord:
"""One ranked metric attached to an immutable publication identity."""
publication_id: str
ranking: RankedMetric
def __post_init__(self) -> None:
"""Validate the publication foreign identity."""
if not self.publication_id.strip():
raise ValueError("publication_id must not be empty")
@dataclass(frozen=True, slots=True)
class DailyAggregateRecord:
"""One exact daily strategy input owned by a publication revision."""
publication_id: str
aggregate: SectorDailyAggregate
def __post_init__(self) -> None:
"""Validate the publication foreign identity."""
if not self.publication_id.strip():
raise ValueError("publication_id must not be empty")
class PublicationSourceGroup(StrEnum):
"""Stable source checkpoints that can be retried independently."""
CALENDAR = "calendar"
CONCEPT_INDICES = "concept_indices"
INDUSTRY_INDICES = "industry_indices"
MEMBERS = "members"
STOCK_BASICS = "stock_basics"
SUSPENSIONS = "suspensions"
DAILY = "daily"
MONEYFLOW_DC = "moneyflow_dc"
@dataclass(frozen=True, slots=True)
class PublicationSourceRecord:
"""One ordered raw snapshot checkpoint attached to a build attempt."""
publication_id: str
source_group: PublicationSourceGroup
source_order: int
snapshot: SourceSnapshot
refresh_on_retry: bool = False
def __post_init__(self) -> None:
"""Validate the publication identity and deterministic group ordering."""
if not self.publication_id.strip():
raise ValueError("publication_id must not be empty")
if self.source_order < 0:
raise ValueError("source_order must not be negative")
@dataclass(frozen=True, slots=True)
class WriteCounts:
"""Idempotent persistence outcome."""
inserted: int
unchanged: int
def __post_init__(self) -> None:
"""Reject impossible write counts."""
if self.inserted < 0 or self.unchanged < 0:
raise ValueError("write counts must not be negative")
class SectorRadarRepository(Protocol):
"""Persist source revisions, normalized facts, and published rankings."""
def advisory_lock(self, target_trade_date: date) -> AbstractContextManager[bool]: ...
def save_source_snapshots(self, snapshots: Iterable[SourceSnapshot]) -> WriteCounts: ...
def save_publication_sources(
self, records: Iterable[PublicationSourceRecord]
) -> WriteCounts: ...
def load_publication_sources(
self, publication_id: str
) -> Sequence[PublicationSourceRecord]: ...
def mark_publication_sources_for_retry(
self,
publication_id: str,
source_groups: Sequence[PublicationSourceGroup],
) -> None: ...
def save_memberships(self, records: Iterable[MembershipRecord]) -> WriteCounts: ...
def save_stock_facts(self, records: Iterable[StockFactRecord]) -> WriteCounts: ...
def save_daily_aggregates(self, records: Iterable[DailyAggregateRecord]) -> WriteCounts: ...
def create_publication(self, publication: RadarPublication) -> WriteCounts: ...
def finish_publication(self, publication: RadarPublication) -> None: ...
def finalize_publication(
self,
publication: RadarPublication,
*,
memberships: Iterable[MembershipRecord],
stock_facts: Iterable[StockFactRecord],
daily_aggregates: Iterable[DailyAggregateRecord],
rankings: Iterable[RankingRecord],
retry_source_groups: Sequence[PublicationSourceGroup] = (),
) -> None: ...
def recover_running_publications(
self, target_trade_date: date, *, finished_at: datetime
) -> Sequence[str]: ...
def discard_running_publication(self, publication_id: str) -> None: ...
def save_rankings(self, records: Iterable[RankingRecord]) -> WriteCounts: ...
def find_reusable_publication(
self, target_trade_date: date, input_hash: str
) -> RadarPublication | None: ...
def get_publication(self, publication_id: str) -> RadarPublication | None: ...
def get_last_good_publication(
self, target_trade_date: date | None = None
) -> RadarPublication | None: ...
def get_successful_publication(self, target_trade_date: date) -> RadarPublication | None: ...
def get_latest_publication(self) -> RadarPublication | None: ...
def list_successful_dates(self) -> Sequence[date]: ...
def load_rankings(self, publication_id: str) -> Sequence[RankedMetric]: ...
def load_daily_aggregate_history(
self, target_trade_date: date, *, limit_dates: int
) -> Sequence[SectorDailyAggregate]: ...
def load_previous_rankings(
self, target_trade_date: date, *, limit_dates: int
) -> Sequence[tuple[date, Sequence[RankedMetric]]]: ...
@@ -0,0 +1,53 @@
"""Application-facing ports for independent sector radar production."""
from __future__ import annotations
from collections.abc import Sequence
from contextlib import AbstractContextManager
from datetime import date
from typing import Protocol
from .models import SectorType
from .source import (
CapabilityProbeResult,
DailyRow,
MoneyflowDcRow,
SectorIndexRow,
SectorMemberRow,
SourceResult,
StockBasicRow,
SuspendRow,
TradeCalendarRow,
)
class SectorRadarSource(Protocol):
"""Fetch the minimum replayable Tushare facts needed by the MVP."""
def fetch_trade_calendar(self, start: date, end: date) -> SourceResult[TradeCalendarRow]: ...
def fetch_sector_indices(
self, trade_date: date, sector_type: SectorType
) -> SourceResult[SectorIndexRow]: ...
def fetch_sector_members(
self,
trade_date: date,
sector_codes: Sequence[str],
) -> SourceResult[SectorMemberRow]: ...
def fetch_stock_basics(self) -> SourceResult[StockBasicRow]: ...
def fetch_suspensions(self, trade_date: date) -> SourceResult[SuspendRow]: ...
def fetch_daily(self, trade_date: date) -> SourceResult[DailyRow]: ...
def fetch_moneyflow_dc(self, trade_date: date) -> SourceResult[MoneyflowDcRow]: ...
def probe(self, trade_date: date) -> CapabilityProbeResult: ...
class SectorRadarLock(Protocol):
"""Repository seam for a target-date advisory lock."""
def advisory_lock(self, target_trade_date: date) -> AbstractContextManager[bool]: ...
@@ -0,0 +1,216 @@
"""Deterministic cross-sectional ranking for independent sector pools."""
from __future__ import annotations
from collections import defaultdict
from collections.abc import Iterable, Mapping
from dataclasses import replace
from datetime import date
from decimal import Decimal
from .models import (
MetricKind,
MetricObservation,
RankChange,
RankedMetric,
RankSide,
SectorType,
)
PoolKey = tuple[date, SectorType, MetricKind, str]
SectorMetricKey = tuple[SectorType, str, MetricKind, str]
def _pool_key(observation: MetricObservation) -> PoolKey:
return (
observation.trade_date,
observation.sector_type,
observation.metric_kind,
observation.metric_version,
)
def _sector_metric_key(observation: MetricObservation) -> SectorMetricKey:
return (
observation.sector_type,
observation.sector_code,
observation.metric_kind,
observation.metric_version,
)
def _available_sort_key(observation: MetricObservation) -> tuple[Decimal, str]:
if observation.value is None:
raise ValueError("unavailable observations cannot use the ranking sort key")
return (-observation.value, observation.sector_code)
def rank_metric_observations(
observations: Iterable[MetricObservation],
) -> tuple[RankedMetric, ...]:
"""Rank observations by value within date, type, metric, and version.
Concept and industry observations never share a pool. Equal metric values
use ascending sector code as the documented Zhixing tie-breaker. Missing
values remain visible but do not consume a rank.
"""
pools: defaultdict[PoolKey, list[MetricObservation]] = defaultdict(list)
for observation in observations:
pools[_pool_key(observation)].append(observation)
result: list[RankedMetric] = []
for pool_key in sorted(
pools,
key=lambda key: (key[0], key[1].value, key[2].value, key[3]),
):
pool = pools[pool_key]
codes = [observation.sector_code for observation in pool]
if len(codes) != len(set(codes)):
raise ValueError("a ranking pool must not contain duplicate sector codes")
available = sorted(
(observation for observation in pool if observation.value is not None),
key=_available_sort_key,
)
pool_size = len(available)
for rank_position, observation in enumerate(available, start=1):
rank_percentile = (
Decimal(100) * Decimal(pool_size - rank_position + 1) / Decimal(pool_size)
)
result.append(
RankedMetric(
observation=observation,
rank_position=rank_position,
rank_percentile=rank_percentile,
)
)
result.extend(
RankedMetric(
observation=observation,
rank_position=None,
rank_percentile=None,
)
for observation in sorted(
(observation for observation in pool if observation.value is None),
key=lambda observation: observation.sector_code,
)
)
return tuple(result)
def select_percentile_side(
rankings: Iterable[RankedMetric], side: RankSide
) -> tuple[RankedMetric, ...]:
"""Select confirmed inclusive percentile sides without fixed row counts."""
rows = tuple(rankings)
if side is RankSide.ALL:
return rows
threshold_rows = tuple(
row
for row in rows
if row.rank_percentile is not None
and (
row.rank_percentile >= Decimal(90)
if side is RankSide.TOP
else row.rank_percentile <= Decimal(10)
)
)
if side is RankSide.TOP:
return threshold_rows
pools: defaultdict[PoolKey, list[RankedMetric]] = defaultdict(list)
for row in threshold_rows:
pools[_pool_key(row.observation)].append(row)
result: list[RankedMetric] = []
for pool_key in sorted(
pools,
key=lambda key: (key[0], key[1].value, key[2].value, key[3]),
):
result.extend(
sorted(
pools[pool_key],
key=lambda row: (
row.observation.value if row.observation.value is not None else Decimal(0),
row.observation.sector_code,
),
)
)
return tuple(result)
def with_rank_changes(
current_rankings: Iterable[RankedMetric],
history_by_days: Mapping[int, Iterable[RankedMetric]],
) -> tuple[RankedMetric, ...]:
"""Attach 1-to-5-day deltas without turning missing history into zero."""
history_indexes: dict[int, dict[SectorMetricKey, int | None]] = {}
for days, historical_rankings in history_by_days.items():
if not 1 <= days <= 5:
raise ValueError("rank change days must be between 1 and 5")
index: dict[SectorMetricKey, int | None] = {}
for row in historical_rankings:
key = _sector_metric_key(row.observation)
if key in index:
raise ValueError("historical rankings must have unique sector metrics")
index[key] = row.rank_position
history_indexes[days] = index
result: list[RankedMetric] = []
for row in current_rankings:
key = _sector_metric_key(row.observation)
changes: list[RankChange] = []
for days in sorted(history_indexes):
past_rank = history_indexes[days].get(key)
value = (
past_rank - row.rank_position
if past_rank is not None and row.rank_position is not None
else None
)
changes.append(RankChange(days=days, value=value))
result.append(replace(row, rank_changes=tuple(changes)))
return tuple(result)
def select_rank_change_side(
rankings: Iterable[RankedMetric],
*,
days: int,
side: RankSide,
) -> tuple[RankedMetric, ...]:
"""Select the strongest or weakest ceiling-ten-percent rank changes per pool."""
if not 1 <= days <= 5:
raise ValueError("rank change days must be between 1 and 5")
pools: defaultdict[PoolKey, list[RankedMetric]] = defaultdict(list)
for row in rankings:
pools[_pool_key(row.observation)].append(row)
result: list[RankedMetric] = []
for pool_key in sorted(
pools,
key=lambda key: (key[0], key[1].value, key[2].value, key[3]),
):
pool = pools[pool_key]
pool_size = sum(row.rank_position is not None for row in pool)
take_count = max(1, (pool_size + 9) // 10) if pool_size else 0
candidates = tuple(
(change, row) for row in pool if (change := row.rank_change(days)) is not None
)
if side is RankSide.BOTTOM:
ordered = sorted(
candidates,
key=lambda item: (item[0], item[1].observation.sector_code),
)
else:
ordered = sorted(
candidates,
key=lambda item: (-item[0], item[1].observation.sector_code),
)
selected = ordered if side is RankSide.ALL else ordered[:take_count]
result.extend(row for _, row in selected)
return tuple(result)
@@ -0,0 +1,477 @@
"""Typed Tushare input contracts and replayable source snapshot values."""
from __future__ import annotations
import hashlib
import json
import math
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import UTC, date, datetime
from decimal import Decimal, InvalidOperation
from enum import StrEnum
from typing import TypeVar
from .models import SectorType
SourceScalar = str | int | float | bool | None
T = TypeVar("T")
class SourceContractError(ValueError):
"""A provider response violates the replayable input contract."""
class SourceTruncatedError(SourceContractError):
"""A provider response reached its row limit without safe partitioning."""
def normalize_source_scalar(value: object) -> SourceScalar:
"""Normalize flat Tushare cells while distinguishing missing from infinity."""
if value is None:
return None
if isinstance(value, bool):
return value
if isinstance(value, int):
return value
if isinstance(value, float):
if math.isnan(value):
return None
if not math.isfinite(value):
raise SourceContractError("source numeric values must be finite")
return value
if isinstance(value, Decimal):
if value.is_nan():
return None
if not value.is_finite():
raise SourceContractError("source numeric values must be finite")
return str(value)
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, date):
return value.isoformat()
if isinstance(value, str):
stripped = value.strip()
if not stripped or stripped.casefold() == "nan":
return None
return stripped
raise SourceContractError(f"unsupported source cell type: {type(value).__name__}")
def normalize_source_rows(
rows: Sequence[Mapping[str, object]],
) -> tuple[dict[str, SourceScalar], ...]:
"""Return safe flat rows with deterministic key order."""
return tuple({key: normalize_source_scalar(row[key]) for key in sorted(row)} for row in rows)
@dataclass(frozen=True, slots=True)
class SourceSnapshot:
"""One raw, sanitized provider response identified by safe content hash."""
snapshot_id: str
api_name: str
normalized_params: tuple[tuple[str, str], ...]
target_trade_date: date | None
partition_key: str | None
observed_at: datetime
rows: tuple[dict[str, SourceScalar], ...]
row_count: int
returned_fields: tuple[str, ...]
content_sha256: str
row_limit: int | None
limit_reached: bool
def __post_init__(self) -> None:
"""Validate replay identity and row metadata."""
for field_name, value in (
("snapshot_id", self.snapshot_id),
("content_sha256", self.content_sha256),
):
if len(value) != 64 or any(character not in "0123456789abcdef" for character in value):
raise ValueError(f"{field_name} must be a lowercase SHA-256 digest")
if not self.api_name.strip():
raise ValueError("api_name must not be empty")
if self.observed_at.tzinfo is None:
raise ValueError("observed_at must be timezone-aware")
if self.row_count != len(self.rows):
raise ValueError("row_count must match rows")
if self.row_limit is not None and self.row_limit < 1:
raise ValueError("row_limit must be positive")
if self.limit_reached != (self.row_limit is not None and self.row_count >= self.row_limit):
raise ValueError("limit_reached must match row_count and row_limit")
def build_source_snapshot(
*,
api_name: str,
params: Mapping[str, object],
rows: Sequence[Mapping[str, object]],
target_trade_date: date | None,
partition_key: str | None = None,
observed_at: datetime | None = None,
row_limit: int | None = None,
returned_fields: Sequence[str] | None = None,
) -> SourceSnapshot:
"""Build an order-stable, token-free raw response snapshot."""
normalized_rows = normalize_source_rows(rows)
normalized_params = tuple(
sorted((key, str(value)) for key, value in params.items() if key != "token")
)
fields = tuple(
sorted(
set(returned_fields)
if returned_fields is not None
else {key for row in normalized_rows for key in row}
)
)
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,
normalized_params=normalized_params,
target_trade_date=target_trade_date,
partition_key=partition_key,
observed_at=observed_at or datetime.now(UTC),
rows=normalized_rows,
row_count=row_count,
returned_fields=fields,
content_sha256=content_sha256,
row_limit=row_limit,
limit_reached=limit_reached,
)
@dataclass(frozen=True, slots=True)
class SourceResult[T]:
"""Typed rows accompanied by every raw request needed to produce them."""
snapshots: tuple[SourceSnapshot, ...]
rows: tuple[T, ...]
def _required_text(row: Mapping[str, SourceScalar], key: str) -> str:
value = row.get(key)
if not isinstance(value, str) or not value.strip():
raise SourceContractError(f"{key} must be a non-empty string")
return value.strip()
def _optional_text(row: Mapping[str, SourceScalar], key: str) -> str | None:
value = row.get(key)
if value is None:
return None
return str(value).strip() or None
def _source_date(
row: Mapping[str, SourceScalar], key: str, *, required: bool = True
) -> date | None:
value = row.get(key)
if value is None:
if required:
raise SourceContractError(f"{key} is required")
return None
text = str(value).strip().replace("-", "")
try:
return datetime.strptime(text, "%Y%m%d").date()
except ValueError as exc:
raise SourceContractError(f"{key} must use YYYYMMDD") from exc
def _decimal(row: Mapping[str, SourceScalar], key: str) -> Decimal | None:
value = row.get(key)
if value is None:
return None
try:
result = Decimal(str(value))
except InvalidOperation as exc:
raise SourceContractError(f"{key} must be numeric or missing") from exc
if result.is_nan():
return None
if not result.is_finite():
raise SourceContractError(f"{key} must be finite")
return result
@dataclass(frozen=True, slots=True)
class TradeCalendarRow:
"""One exchange calendar observation."""
exchange: str
cal_date: date
is_open: bool
pretrade_date: date | None
@classmethod
def from_mapping(cls, row: Mapping[str, SourceScalar]) -> TradeCalendarRow:
"""Parse one Tushare ``trade_cal`` row."""
cal_date = _source_date(row, "cal_date")
assert cal_date is not None
return cls(
exchange=_optional_text(row, "exchange") or "",
cal_date=cal_date,
is_open=str(row.get("is_open")).strip().casefold() in {"1", "true"},
pretrade_date=_source_date(row, "pretrade_date", required=False),
)
@dataclass(frozen=True, slots=True)
class SectorIndexRow:
"""One Eastmoney concept or industry identity on a trade date."""
trade_date: date
sector_type: SectorType
sector_code: str
name: str
level: str | None
pct_change: Decimal | None
leading_code: str | None
@classmethod
def from_mapping(
cls,
row: Mapping[str, SourceScalar],
sector_type: SectorType,
) -> SectorIndexRow:
"""Parse and validate one ``dc_index`` row."""
trade_date = _source_date(row, "trade_date")
assert trade_date is not None
return cls(
trade_date=trade_date,
sector_type=sector_type,
sector_code=_required_text(row, "ts_code"),
name=_required_text(row, "name"),
level=_optional_text(row, "level"),
pct_change=_decimal(row, "pct_change"),
leading_code=_optional_text(row, "leading_code"),
)
@dataclass(frozen=True, slots=True)
class SectorMemberRow:
"""One point-in-time sector member returned by ``dc_member``."""
trade_date: date
sector_code: str
stock_code: str
stock_name: str
@classmethod
def from_mapping(cls, row: Mapping[str, SourceScalar]) -> SectorMemberRow:
"""Parse one dated membership row."""
trade_date = _source_date(row, "trade_date")
assert trade_date is not None
return cls(
trade_date=trade_date,
sector_code=_required_text(row, "ts_code"),
stock_code=_required_text(row, "con_code"),
stock_name=_required_text(row, "name"),
)
@dataclass(frozen=True, slots=True)
class StockBasicRow:
"""Lifecycle and market identity from one explicit listing-status query."""
ts_code: str
symbol: str
name: str
market: str
exchange: str
list_status: str
list_date: date | None
delist_date: date | None
@classmethod
def from_mapping(cls, row: Mapping[str, SourceScalar]) -> StockBasicRow:
"""Parse one ``stock_basic`` row without applying ST filtering."""
return cls(
ts_code=_required_text(row, "ts_code"),
symbol=_required_text(row, "symbol"),
name=_required_text(row, "name"),
market=_required_text(row, "market"),
exchange=_required_text(row, "exchange"),
list_status=_required_text(row, "list_status"),
list_date=_source_date(row, "list_date", required=False),
delist_date=_source_date(row, "delist_date", required=False),
)
@dataclass(frozen=True, slots=True)
class SuspendRow:
"""One daily suspend/resume event."""
ts_code: str
trade_date: date
suspend_timing: str
suspend_type: str
@classmethod
def from_mapping(cls, row: Mapping[str, SourceScalar]) -> SuspendRow:
"""Parse one ``suspend_d`` row."""
trade_date = _source_date(row, "trade_date")
assert trade_date is not None
return cls(
ts_code=_required_text(row, "ts_code"),
trade_date=trade_date,
suspend_timing=_required_text(row, "suspend_timing"),
suspend_type=_required_text(row, "suspend_type"),
)
@dataclass(frozen=True, slots=True)
class DailyRow:
"""One stock daily row retaining Tushare's thousand-yuan amount."""
ts_code: str
trade_date: date
close: Decimal | None
pre_close: Decimal | None
pct_chg: Decimal | None
volume: Decimal | None
amount_thousand_yuan: Decimal | None
@property
def turnover_yuan(self) -> Decimal | None:
"""Convert observed turnover to yuan without inventing missing values."""
return (
None if self.amount_thousand_yuan is None else self.amount_thousand_yuan * Decimal(1000)
)
@classmethod
def from_mapping(cls, row: Mapping[str, SourceScalar]) -> DailyRow:
"""Parse one ``daily`` row."""
trade_date = _source_date(row, "trade_date")
assert trade_date is not None
return cls(
ts_code=_required_text(row, "ts_code"),
trade_date=trade_date,
close=_decimal(row, "close"),
pre_close=_decimal(row, "pre_close"),
pct_chg=_decimal(row, "pct_chg"),
volume=_decimal(row, "vol"),
amount_thousand_yuan=_decimal(row, "amount"),
)
@dataclass(frozen=True, slots=True)
class MoneyflowDcRow:
"""One stock main-moneyflow row retaining Tushare's ten-thousand-yuan amount."""
trade_date: date
ts_code: str
name: str
net_amount_ten_thousand_yuan: Decimal | None
net_amount_rate: Decimal | None
pct_change: Decimal | None
close: Decimal | None
@property
def net_amount_yuan(self) -> Decimal | None:
"""Convert observed main net amount to yuan without filling NULL as zero."""
return (
None
if self.net_amount_ten_thousand_yuan is None
else self.net_amount_ten_thousand_yuan * Decimal(10_000)
)
@classmethod
def from_mapping(cls, row: Mapping[str, SourceScalar]) -> MoneyflowDcRow:
"""Parse one ``moneyflow_dc`` row."""
trade_date = _source_date(row, "trade_date")
assert trade_date is not None
return cls(
trade_date=trade_date,
ts_code=_required_text(row, "ts_code"),
name=_required_text(row, "name"),
net_amount_ten_thousand_yuan=_decimal(row, "net_amount"),
net_amount_rate=_decimal(row, "net_amount_rate"),
pct_change=_decimal(row, "pct_change"),
close=_decimal(row, "close"),
)
class CapabilityStatus(StrEnum):
"""Safe capability outcomes that never expose provider error text."""
OK = "ok"
FORBIDDEN = "forbidden"
RATE_LIMITED = "rate_limited"
SERVER_ERROR = "server_error"
SCHEMA_ERROR = "schema_error"
TRUNCATED = "truncated"
@dataclass(frozen=True, slots=True)
class CapabilityInterfaceResult:
"""Safe, credential-free observation for one required interface."""
api_name: str
requested_fields: tuple[str, ...]
returned_fields: tuple[str, ...]
status: CapabilityStatus
row_count: int
row_limit: int | None
retryable: bool
@dataclass(frozen=True, slots=True)
class CapabilityProbeResult:
"""Read-only account capability report for the seven MVP interfaces."""
observed_at: datetime
interfaces: tuple[CapabilityInterfaceResult, ...]
@property
def succeeded(self) -> bool:
"""Return whether every required interface passed its probe."""
return bool(self.interfaces) and all(
result.status is CapabilityStatus.OK for result in self.interfaces
)
@@ -0,0 +1 @@
"""Infrastructure adapters for the sector radar bounded context."""
@@ -0,0 +1,460 @@
"""Deterministic in-memory repository used by application and contract tests."""
from __future__ import annotations
from collections.abc import Callable, Generator, Iterable, Sequence
from contextlib import contextmanager
from dataclasses import replace
from datetime import date, datetime
from ..domain.models import PublicationStatus, RadarPublication, RankedMetric, SectorDailyAggregate
from ..domain.persistence import (
DailyAggregateRecord,
MembershipRecord,
PublicationSourceGroup,
PublicationSourceRecord,
RankingRecord,
StockFactRecord,
WriteCounts,
)
from ..domain.source import SourceSnapshot
class InMemorySectorRadarRepository:
"""Keep immutable radar revisions in dictionaries without hiding overwrites."""
def __init__(self) -> None:
self.source_snapshots: dict[str, SourceSnapshot] = {}
self.publication_sources: dict[tuple[str, str, int], PublicationSourceRecord] = {}
self.memberships: dict[tuple[str, str, str], MembershipRecord] = {}
self.stock_facts: dict[tuple[str, str], StockFactRecord] = {}
self.publications: dict[str, RadarPublication] = {}
self.daily_aggregates: dict[tuple[str, str, str], DailyAggregateRecord] = {}
self.rankings: dict[tuple[str, str, str, str], RankingRecord] = {}
self.lock_available = True
@contextmanager
def advisory_lock(self, target_trade_date: date) -> Generator[bool]:
"""Expose a controllable lock result for build orchestration tests."""
del target_trade_date
yield self.lock_available
def save_source_snapshots(self, snapshots: Iterable[SourceSnapshot]) -> WriteCounts:
"""Insert new content-addressed snapshots and count identical replays."""
inserted = 0
unchanged = 0
seen: set[str] = set()
for snapshot in snapshots:
if snapshot.snapshot_id in seen:
raise ValueError("one write batch must not contain duplicate business keys")
seen.add(snapshot.snapshot_id)
if snapshot.snapshot_id in self.source_snapshots:
unchanged += 1
else:
self.source_snapshots[snapshot.snapshot_id] = snapshot
inserted += 1
return WriteCounts(inserted, unchanged)
def save_publication_sources(self, records: Iterable[PublicationSourceRecord]) -> WriteCounts:
"""Checkpoint completed source groups under one publication attempt."""
items = tuple(records)
for item in items:
if item.publication_id not in self.publications:
raise ValueError("publication source publication does not exist")
if item.snapshot.snapshot_id not in self.source_snapshots:
raise ValueError("publication source snapshot does not exist")
return self._insert_immutable(
self.publication_sources,
items,
key=lambda item: (
item.publication_id,
item.source_group.value,
item.source_order,
),
)
def load_publication_sources(self, publication_id: str) -> Sequence[PublicationSourceRecord]:
"""Load source checkpoints in stable group and request order."""
return tuple(
sorted(
(
item
for item in self.publication_sources.values()
if item.publication_id == publication_id
),
key=lambda item: (item.source_group.value, item.source_order),
)
)
def mark_publication_sources_for_retry(
self,
publication_id: str,
source_groups: Sequence[PublicationSourceGroup],
) -> None:
"""Mark only incomplete source groups for a future partial retry."""
requested = set(source_groups)
for key, record in tuple(self.publication_sources.items()):
if record.publication_id == publication_id and record.source_group in requested:
self.publication_sources[key] = replace(record, refresh_on_retry=True)
def save_memberships(self, records: Iterable[MembershipRecord]) -> WriteCounts:
"""Insert membership rows without overwriting an earlier source revision."""
return self._insert_immutable(
self.memberships,
records,
key=lambda item: (
item.source_snapshot_id,
item.sector_code,
item.membership_key,
),
)
def save_stock_facts(self, records: Iterable[StockFactRecord]) -> WriteCounts:
"""Insert normalized fact revisions idempotently."""
return self._insert_immutable(
self.stock_facts,
records,
key=lambda item: (item.fact_revision, item.ts_code),
)
def save_daily_aggregates(self, records: Iterable[DailyAggregateRecord]) -> WriteCounts:
"""Insert publication-owned exact strategy inputs idempotently."""
items = tuple(records)
for item in items:
if item.publication_id not in self.publications:
raise ValueError("daily aggregate publication does not exist")
return self._insert_immutable(
self.daily_aggregates,
items,
key=lambda item: (
item.publication_id,
item.aggregate.sector_type.value,
item.aggregate.sector_code,
),
)
def create_publication(self, publication: RadarPublication) -> WriteCounts:
"""Create one running publication without replacing an existing identity."""
if publication.status is not PublicationStatus.RUNNING:
raise ValueError("new publications must start in running status")
if any(
item.status is PublicationStatus.RUNNING
and item.target_trade_date == publication.target_trade_date
and item.publication_id != publication.publication_id
for item in self.publications.values()
):
raise ValueError("target date already has a running publication")
return self._insert_immutable(
self.publications,
(publication,),
key=lambda item: item.publication_id,
)
def finish_publication(self, publication: RadarPublication) -> None:
"""Apply the sole allowed mutation: running to one terminal audit state."""
if publication.status is PublicationStatus.RUNNING:
raise ValueError("finished publication must use a terminal status")
current = self.publications.get(publication.publication_id)
if current is None or current.status is not PublicationStatus.RUNNING:
raise ValueError("publication must exist in running status")
if current.target_trade_date != publication.target_trade_date:
raise ValueError("publication target_trade_date cannot change")
self.publications[publication.publication_id] = publication
def finalize_publication(
self,
publication: RadarPublication,
*,
memberships: Iterable[MembershipRecord],
stock_facts: Iterable[StockFactRecord],
daily_aggregates: Iterable[DailyAggregateRecord],
rankings: Iterable[RankingRecord],
retry_source_groups: Sequence[PublicationSourceGroup] = (),
) -> None:
"""Atomically expose all derived rows and the terminal publication in tests."""
previous = (
self.memberships.copy(),
self.stock_facts.copy(),
self.daily_aggregates.copy(),
self.rankings.copy(),
self.publication_sources.copy(),
self.publications.copy(),
)
try:
self.save_memberships(memberships)
self.save_stock_facts(stock_facts)
self.save_daily_aggregates(daily_aggregates)
self.save_rankings(rankings)
self.mark_publication_sources_for_retry(
publication.publication_id,
retry_source_groups,
)
self.finish_publication(publication)
except Exception:
(
self.memberships,
self.stock_facts,
self.daily_aggregates,
self.rankings,
self.publication_sources,
self.publications,
) = previous
raise
def recover_running_publications(
self, target_trade_date: date, *, finished_at: datetime
) -> Sequence[str]:
"""Fail orphaned attempts after the caller has acquired the date lock."""
recovered: list[str] = []
for publication_id, publication in tuple(self.publications.items()):
if (
publication.target_trade_date == target_trade_date
and publication.status is PublicationStatus.RUNNING
):
self.publications[publication_id] = replace(
publication,
status=PublicationStatus.FAILED,
finished_at=finished_at,
error_summary="recovered_stale_running",
)
recovered.append(publication_id)
return tuple(sorted(recovered))
def discard_running_publication(self, publication_id: str) -> None:
"""Remove only a provisional duplicate attempt and its owned projections."""
publication = self.publications.get(publication_id)
if publication is None or publication.status is not PublicationStatus.RUNNING:
raise ValueError("discarded publication must exist in running status")
del self.publications[publication_id]
self.publication_sources = {
key: item
for key, item in self.publication_sources.items()
if item.publication_id != publication_id
}
self.daily_aggregates = {
key: item
for key, item in self.daily_aggregates.items()
if item.publication_id != publication_id
}
self.rankings = {
key: item
for key, item in self.rankings.items()
if item.publication_id != publication_id
}
def save_rankings(self, records: Iterable[RankingRecord]) -> WriteCounts:
"""Insert publication-owned rankings idempotently."""
items = tuple(records)
for item in items:
if item.publication_id not in self.publications:
raise ValueError("ranking publication does not exist")
return self._insert_immutable(
self.rankings,
items,
key=lambda item: (
item.publication_id,
item.ranking.observation.sector_type.value,
item.ranking.observation.sector_code,
item.ranking.observation.metric_version,
),
)
def get_publication(self, publication_id: str) -> RadarPublication | None:
"""Return one publication revision by identity."""
return self.publications.get(publication_id)
def find_reusable_publication(
self, target_trade_date: date, input_hash: str
) -> RadarPublication | None:
"""Find an identical success or partial revision without hiding failures."""
return max(
(
item
for item in self.publications.values()
if item.status in {PublicationStatus.SUCCESS, PublicationStatus.PARTIAL}
and item.target_trade_date == target_trade_date
and item.input_hash == input_hash
),
key=lambda item: (item.finished_at or item.started_at, item.publication_id),
default=None,
)
def get_last_good_publication(
self, target_trade_date: date | None = None
) -> RadarPublication | None:
"""Return only a successful publication; partial and failed never qualify."""
candidates = tuple(
publication
for publication in self.publications.values()
if publication.status is PublicationStatus.SUCCESS
and (target_trade_date is None or publication.target_trade_date <= target_trade_date)
)
return max(
candidates,
key=lambda item: (
item.target_trade_date,
item.finished_at or item.started_at,
item.publication_id,
),
default=None,
)
def get_successful_publication(self, target_trade_date: date) -> RadarPublication | None:
"""Return the latest successful revision for exactly one date."""
candidates = tuple(
publication
for publication in self.publications.values()
if publication.status is PublicationStatus.SUCCESS
and publication.target_trade_date == target_trade_date
)
return max(
candidates,
key=lambda item: (item.finished_at or item.started_at, item.publication_id),
default=None,
)
def get_latest_publication(self) -> RadarPublication | None:
"""Return the newest build attempt regardless of terminal status."""
return max(
self.publications.values(),
key=lambda item: (item.target_trade_date, item.started_at, item.publication_id),
default=None,
)
def list_successful_dates(self) -> Sequence[date]:
"""Return distinct successful dates newest first."""
return tuple(
sorted(
{
item.target_trade_date
for item in self.publications.values()
if item.status is PublicationStatus.SUCCESS
},
reverse=True,
)
)
def load_rankings(self, publication_id: str) -> Sequence[RankedMetric]:
"""Load every ranking projection owned by one publication."""
return tuple(
sorted(
(
record.ranking
for record in self.rankings.values()
if record.publication_id == publication_id
),
key=lambda row: (
row.observation.sector_type.value,
row.observation.metric_version,
row.rank_position is None,
row.rank_position or 0,
row.observation.sector_code,
),
)
)
def load_daily_aggregate_history(
self, target_trade_date: date, *, limit_dates: int
) -> Sequence[SectorDailyAggregate]:
"""Load aggregates from the latest successful revision of prior dates."""
dates = self._previous_successful_dates(target_trade_date, limit_dates)
selected_publications = {
self._latest_success_for_date(item).publication_id for item in dates
}
return tuple(
record.aggregate
for record in self.daily_aggregates.values()
if record.publication_id in selected_publications
)
def load_previous_rankings(
self, target_trade_date: date, *, limit_dates: int
) -> Sequence[tuple[date, Sequence[RankedMetric]]]:
"""Load prior successful rankings newest first for rank-change attachment."""
result: list[tuple[date, Sequence[RankedMetric]]] = []
for trade_date in self._previous_successful_dates(target_trade_date, limit_dates):
publication_id = self._latest_success_for_date(trade_date).publication_id
result.append(
(
trade_date,
tuple(
record.ranking
for record in self.rankings.values()
if record.publication_id == publication_id
),
)
)
return tuple(result)
def _previous_successful_dates(self, target_trade_date: date, limit: int) -> tuple[date, ...]:
if limit < 1:
raise ValueError("limit_dates must be positive")
return tuple(
sorted(
{
item.target_trade_date
for item in self.publications.values()
if item.status is PublicationStatus.SUCCESS
and item.target_trade_date < target_trade_date
},
reverse=True,
)[:limit]
)
def _latest_success_for_date(self, trade_date: date) -> RadarPublication:
return max(
(
item
for item in self.publications.values()
if item.status is PublicationStatus.SUCCESS and item.target_trade_date == trade_date
),
key=lambda item: (item.finished_at or item.started_at, item.publication_id),
)
@staticmethod
def _insert_immutable[K, V](
target: dict[K, V],
values: Iterable[V],
*,
key: Callable[[V], K],
) -> WriteCounts:
inserted = 0
unchanged = 0
seen: set[K] = set()
for value in values:
item_key = key(value)
if item_key in seen:
raise ValueError("one write batch must not contain duplicate business keys")
seen.add(item_key)
existing = target.get(item_key)
if existing is None:
target[item_key] = value
inserted += 1
elif existing == value:
unchanged += 1
else:
raise ValueError("immutable revision identity cannot change content")
return WriteCounts(inserted=inserted, unchanged=unchanged)
@@ -0,0 +1,488 @@
"""Tushare adapter for replayable sector radar source facts."""
from __future__ import annotations
import time
from collections.abc import Callable, Iterable, Mapping, Sequence
from datetime import UTC, date, datetime
from typing import TypeVar, cast
from zhixing_server.shared.request_coordinator import (
DEFAULT_RATE_LIMIT_COOLDOWNS,
RequestCoordinator,
)
from ..domain.models import SectorType
from ..domain.source import (
CapabilityInterfaceResult,
CapabilityProbeResult,
CapabilityStatus,
DailyRow,
MoneyflowDcRow,
SectorIndexRow,
SectorMemberRow,
SourceContractError,
SourceResult,
SourceSnapshot,
SourceTruncatedError,
StockBasicRow,
SuspendRow,
TradeCalendarRow,
build_source_snapshot,
)
T = TypeVar("T")
FIELDS: dict[str, tuple[str, ...]] = {
"trade_cal": ("exchange", "cal_date", "is_open", "pretrade_date"),
"dc_index": (
"ts_code",
"trade_date",
"name",
"idx_type",
"level",
"pct_change",
"leading_code",
),
"dc_member": ("trade_date", "ts_code", "con_code", "name"),
"stock_basic": (
"ts_code",
"symbol",
"name",
"market",
"exchange",
"list_status",
"list_date",
"delist_date",
),
"suspend_d": ("ts_code", "trade_date", "suspend_timing", "suspend_type"),
"daily": ("ts_code", "trade_date", "close", "pre_close", "pct_chg", "vol", "amount"),
"moneyflow_dc": (
"trade_date",
"ts_code",
"name",
"net_amount",
"net_amount_rate",
"pct_change",
"close",
),
}
ROW_LIMITS: dict[str, int | None] = {
"trade_cal": None,
"dc_index": 5_000,
"dc_member": 5_000,
"stock_basic": None,
"suspend_d": None,
"daily": 6_000,
"moneyflow_dc": 6_000,
}
_SECTOR_TYPE_PARAM = {
SectorType.CONCEPT: "概念板块",
SectorType.INDUSTRY: "行业板块",
}
class TushareSectorRadarAdapter:
"""Fetch seven Tushare interfaces with schema, limit, and replay metadata."""
def __init__(
self,
client: object,
*,
request_coordinator: RequestCoordinator | None = None,
max_retries: int = 3,
backoff_seconds: float = 1.0,
request_interval_seconds: float = 0.2,
cooldown_seconds: Sequence[float] = DEFAULT_RATE_LIMIT_COOLDOWNS,
sleep_fn: Callable[[float], None] = time.sleep,
now_fn: Callable[[], datetime] = lambda: datetime.now(UTC),
) -> None:
"""Create an adapter around one already-authenticated SDK client."""
self._client = client
self._sleep_fn = sleep_fn
self._request_interval_seconds = max(0.0, request_interval_seconds)
self._now_fn = now_fn
self._coordinator = request_coordinator or RequestCoordinator(
max_retries=max_retries,
backoff_seconds=backoff_seconds,
cooldown_seconds=cooldown_seconds,
wait_fn=sleep_fn,
sleep_fn=sleep_fn,
)
@classmethod
def from_token(
cls,
token: str,
*,
max_retries: int = 3,
backoff_seconds: float = 1.0,
request_interval_seconds: float = 0.2,
cooldown_seconds: Sequence[float] = DEFAULT_RATE_LIMIT_COOLDOWNS,
) -> TushareSectorRadarAdapter:
"""Create a production client without calling ``set_token`` or retaining the token."""
if not token.strip():
raise ValueError("ZHIXING_TUSHARE_TOKEN is required for sector radar")
import tushare as ts # pyright: ignore[reportMissingTypeStubs]
return cls(
cast(object, ts.pro_api(token)),
max_retries=max_retries,
backoff_seconds=backoff_seconds,
request_interval_seconds=request_interval_seconds,
cooldown_seconds=cooldown_seconds,
)
def fetch_trade_calendar(self, start: date, end: date) -> SourceResult[TradeCalendarRow]:
"""Fetch and validate an inclusive exchange calendar range."""
if end < start:
raise ValueError("end must not precede start")
snapshot = self._fetch_snapshot(
"trade_cal",
{
"exchange": "",
"start_date": start.strftime("%Y%m%d"),
"end_date": end.strftime("%Y%m%d"),
},
target_trade_date=end,
)
rows = tuple(TradeCalendarRow.from_mapping(row) for row in snapshot.rows)
self._require_unique(
rows, key=lambda row: (row.exchange, row.cal_date), api_name="trade_cal"
)
return SourceResult((snapshot,), tuple(sorted(rows, key=lambda row: row.cal_date)))
def fetch_sector_indices(
self,
trade_date: date,
sector_type: SectorType,
) -> SourceResult[SectorIndexRow]:
"""Fetch one independent concept or industry universe."""
idx_type = _SECTOR_TYPE_PARAM[sector_type]
snapshot = self._fetch_snapshot(
"dc_index",
{"trade_date": trade_date.strftime("%Y%m%d"), "idx_type": idx_type},
target_trade_date=trade_date,
partition_key=sector_type.value,
)
if any(str(row.get("idx_type")) != idx_type for row in snapshot.rows):
raise SourceContractError("dc_index returned a different idx_type")
self._reject_limit(snapshot)
rows = tuple(SectorIndexRow.from_mapping(row, sector_type) for row in snapshot.rows)
self._require_target_date(rows, trade_date, "dc_index")
self._require_unique(rows, key=lambda row: row.sector_code, api_name="dc_index")
return SourceResult((snapshot,), tuple(sorted(rows, key=lambda row: row.sector_code)))
def fetch_sector_members(
self,
trade_date: date,
sector_codes: Sequence[str],
) -> SourceResult[SectorMemberRow]:
"""Fetch dated members and partition when the all-market result is incomplete."""
expected_codes = tuple(sorted(set(sector_codes)))
if len(expected_codes) != len(sector_codes) or any(
not code.strip() for code in expected_codes
):
raise ValueError("sector_codes must contain unique non-empty values")
initial = self._fetch_snapshot(
"dc_member",
{"trade_date": trade_date.strftime("%Y%m%d")},
target_trade_date=trade_date,
partition_key="all",
)
initial_rows = tuple(SectorMemberRow.from_mapping(row) for row in initial.rows)
self._require_target_date(initial_rows, trade_date, "dc_member")
returned_codes = {row.sector_code for row in initial_rows}
missing_codes = tuple(code for code in expected_codes if code not in returned_codes)
if initial.limit_reached:
partition_codes = expected_codes
merged_rows: list[SectorMemberRow] = []
snapshots: list[SourceSnapshot] = [initial]
else:
partition_codes = missing_codes
merged_rows = list(initial_rows)
snapshots = [initial]
if initial.limit_reached and not partition_codes:
raise SourceTruncatedError("dc_member reached its limit without sector partitions")
for sector_code in partition_codes:
snapshot = self._fetch_snapshot(
"dc_member",
{
"trade_date": trade_date.strftime("%Y%m%d"),
"ts_code": sector_code,
},
target_trade_date=trade_date,
partition_key=sector_code,
)
self._reject_limit(snapshot)
partition_rows = tuple(SectorMemberRow.from_mapping(row) for row in snapshot.rows)
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")
snapshots.append(snapshot)
merged_rows.extend(partition_rows)
self._require_unique(
merged_rows,
key=lambda row: (row.trade_date, row.sector_code, row.stock_code),
api_name="dc_member",
)
final_codes = {row.sector_code for row in merged_rows}
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),
tuple(sorted(merged_rows, key=lambda row: (row.sector_code, row.stock_code))),
)
def fetch_stock_basics(self) -> SourceResult[StockBasicRow]:
"""Fetch every documented listing status instead of relying on the L default."""
snapshots: list[SourceSnapshot] = []
rows: list[StockBasicRow] = []
for status in ("L", "D", "P", "G", "UN"):
snapshot = self._fetch_snapshot(
"stock_basic",
{"exchange": "", "list_status": status},
target_trade_date=None,
partition_key=status,
)
snapshots.append(snapshot)
parsed = tuple(StockBasicRow.from_mapping(row) for row in snapshot.rows)
if any(row.list_status != status for row in parsed):
raise SourceContractError("stock_basic returned an unexpected list_status")
rows.extend(parsed)
self._require_unique(rows, key=lambda row: row.ts_code, api_name="stock_basic")
return SourceResult(tuple(snapshots), tuple(sorted(rows, key=lambda row: row.ts_code)))
def fetch_suspensions(self, trade_date: date) -> SourceResult[SuspendRow]:
"""Fetch explicit suspend/resume events for one date."""
snapshot = self._fetch_snapshot(
"suspend_d",
{"trade_date": trade_date.strftime("%Y%m%d")},
target_trade_date=trade_date,
)
rows = tuple(SuspendRow.from_mapping(row) for row in snapshot.rows)
self._require_target_date(rows, trade_date, "suspend_d")
self._require_unique(
rows,
key=lambda row: (row.ts_code, row.trade_date, row.suspend_type, row.suspend_timing),
api_name="suspend_d",
)
return SourceResult((snapshot,), tuple(sorted(rows, key=lambda row: row.ts_code)))
def fetch_daily(self, trade_date: date) -> SourceResult[DailyRow]:
"""Fetch a full-market daily snapshot in its documented source unit."""
snapshot = self._fetch_snapshot(
"daily",
{"trade_date": trade_date.strftime("%Y%m%d")},
target_trade_date=trade_date,
)
self._reject_limit(snapshot)
rows = tuple(DailyRow.from_mapping(row) for row in snapshot.rows)
self._require_target_date(rows, trade_date, "daily")
self._require_unique(rows, key=lambda row: row.ts_code, api_name="daily")
return SourceResult((snapshot,), tuple(sorted(rows, key=lambda row: row.ts_code)))
def fetch_moneyflow_dc(self, trade_date: date) -> SourceResult[MoneyflowDcRow]:
"""Fetch a full-market DC moneyflow snapshot in its documented source unit."""
snapshot = self._fetch_snapshot(
"moneyflow_dc",
{"trade_date": trade_date.strftime("%Y%m%d")},
target_trade_date=trade_date,
)
self._reject_limit(snapshot)
rows = tuple(MoneyflowDcRow.from_mapping(row) for row in snapshot.rows)
self._require_target_date(rows, trade_date, "moneyflow_dc")
self._require_unique(rows, key=lambda row: row.ts_code, api_name="moneyflow_dc")
return SourceResult((snapshot,), tuple(sorted(rows, key=lambda row: row.ts_code)))
def probe(self, trade_date: date) -> CapabilityProbeResult:
"""Probe required interfaces while returning only safe classifications."""
results: list[CapabilityInterfaceResult] = []
concept_codes: tuple[str, ...] = ()
calendar = self._probe_call(
"trade_cal", lambda: self.fetch_trade_calendar(trade_date, trade_date)
)
results.append(calendar[0])
try:
concept = self.fetch_sector_indices(trade_date, SectorType.CONCEPT)
industry = self.fetch_sector_indices(trade_date, SectorType.INDUSTRY)
combined = SourceResult(
concept.snapshots + industry.snapshots,
concept.rows + industry.rows,
)
concept_codes = tuple(row.sector_code for row in combined.rows)
results.append(self._capability_success("dc_index", combined.snapshots))
except Exception as exc:
results.append(self._capability_failure("dc_index", exc))
member = self._probe_call(
"dc_member", lambda: self.fetch_sector_members(trade_date, concept_codes)
)
results.append(member[0])
for api_name, operation in (
("stock_basic", self.fetch_stock_basics),
("suspend_d", lambda: self.fetch_suspensions(trade_date)),
("daily", lambda: self.fetch_daily(trade_date)),
("moneyflow_dc", lambda: self.fetch_moneyflow_dc(trade_date)),
):
results.append(self._probe_call(api_name, operation)[0])
return CapabilityProbeResult(observed_at=self._now_fn(), interfaces=tuple(results))
def _fetch_snapshot(
self,
api_name: str,
params: Mapping[str, object],
*,
target_trade_date: date | None,
partition_key: str | None = None,
) -> SourceSnapshot:
fields = ",".join(FIELDS[api_name])
def request() -> object:
query = getattr(self._client, "query", None)
if callable(query):
return query(api_name, fields=fields, **params)
method = getattr(self._client, api_name, None)
if not callable(method):
raise TypeError(f"Tushare client has no callable {api_name}")
return method(fields=fields, **params)
result = self._coordinator.call(api_name, request)
self._sleep_fn(self._request_interval_seconds)
columns = getattr(result, "columns", None)
returned_fields = (
tuple(str(column) for column in cast(Iterable[object], columns))
if isinstance(columns, Iterable) and not isinstance(columns, (str, bytes))
else None
)
rows = self._as_records(result)
snapshot = build_source_snapshot(
api_name=api_name,
params={**params, "fields": fields},
rows=rows,
target_trade_date=target_trade_date,
partition_key=partition_key,
observed_at=self._now_fn(),
row_limit=ROW_LIMITS[api_name],
returned_fields=returned_fields,
)
missing_fields = set(FIELDS[api_name]) - set(snapshot.returned_fields)
if snapshot.returned_fields and missing_fields:
raise SourceContractError(f"{api_name} response is missing requested fields")
return snapshot
@staticmethod
def _as_records(result: object) -> tuple[Mapping[str, object], ...]:
if result is None:
return ()
to_dict = getattr(result, "to_dict", None)
if callable(to_dict):
result = to_dict("records")
if isinstance(result, Mapping):
return (cast(Mapping[str, object], result),)
if isinstance(result, Iterable) and not isinstance(result, (str, bytes)):
records: list[Mapping[str, object]] = []
for row in cast(Iterable[object], result):
if not isinstance(row, Mapping):
raise SourceContractError("Tushare rows must be mappings")
records.append(cast(Mapping[str, object], row))
return tuple(records)
raise SourceContractError("unsupported Tushare tabular response")
@staticmethod
def _reject_limit(snapshot: SourceSnapshot) -> None:
if snapshot.limit_reached:
raise SourceTruncatedError(f"{snapshot.api_name} reached its provider row limit")
@staticmethod
def _require_target_date(rows: Sequence[object], target: date, api_name: str) -> None:
if any(getattr(row, "trade_date", None) != target for row in rows):
raise SourceContractError(f"{api_name} returned a different trade_date")
@staticmethod
def _require_unique(
rows: Sequence[T],
*,
key: Callable[[T], object],
api_name: str,
) -> None:
keys = [key(row) for row in rows]
if len(keys) != len(set(keys)):
raise SourceContractError(f"{api_name} returned duplicate business keys")
def _probe_call(
self,
api_name: str,
operation: Callable[[], SourceResult[object]],
) -> tuple[CapabilityInterfaceResult, SourceResult[object] | None]:
try:
result = operation()
except Exception as exc:
return self._capability_failure(api_name, exc), None
return self._capability_success(api_name, result.snapshots), result
@staticmethod
def _capability_success(
api_name: str,
snapshots: Sequence[SourceSnapshot],
) -> CapabilityInterfaceResult:
return CapabilityInterfaceResult(
api_name=api_name,
requested_fields=FIELDS[api_name],
returned_fields=tuple(
sorted({field for item in snapshots for field in item.returned_fields})
),
status=CapabilityStatus.OK,
row_count=sum(item.row_count for item in snapshots),
row_limit=ROW_LIMITS[api_name],
retryable=False,
)
@staticmethod
def _capability_failure(api_name: str, error: BaseException) -> CapabilityInterfaceResult:
classified_error = error.__cause__ if error.__cause__ is not None else error
message = str(classified_error).casefold()
if isinstance(error, SourceTruncatedError):
status = CapabilityStatus.TRUNCATED
elif RequestCoordinator.is_rate_limited(error) or RequestCoordinator.is_rate_limited(
classified_error
):
status = CapabilityStatus.RATE_LIMITED
elif "权限" in message or "forbidden" in message or "permission" in message:
status = CapabilityStatus.FORBIDDEN
elif isinstance(error, (SourceContractError, ValueError, TypeError)):
status = CapabilityStatus.SCHEMA_ERROR
else:
status = CapabilityStatus.SERVER_ERROR
return CapabilityInterfaceResult(
api_name=api_name,
requested_fields=FIELDS[api_name],
returned_fields=(),
status=status,
row_count=0,
row_limit=ROW_LIMITS[api_name],
retryable=status in {CapabilityStatus.RATE_LIMITED, CapabilityStatus.SERVER_ERROR},
)
@@ -0,0 +1 @@
"""Delivery adapters for sector radar build and query use cases."""
@@ -0,0 +1,108 @@
"""One-shot ``sector-radar-build`` command for external schedulers."""
from __future__ import annotations
import argparse
import json
import logging
from collections.abc import Sequence
from datetime import date
from ....bootstrap.config import get_settings
from ..application.build import BuildSectorRadar, BuildSectorRadarCommand
from ..infrastructure.postgres import PostgresSectorRadarRepository
from ..infrastructure.tushare import TushareSectorRadarAdapter
logger = logging.getLogger(__name__)
def build_parser() -> argparse.ArgumentParser:
"""Build mutually exclusive single-date, range, and retry modes."""
parser = argparse.ArgumentParser(
description="Build independent Tushare sector radar publications"
)
mode = parser.add_mutually_exclusive_group()
mode.add_argument("--trade-date", type=_parse_date, help="target date in YYYY-MM-DD")
mode.add_argument(
"--retry-publication-id",
help="resume failed source groups from a partial or failed publication",
)
mode.add_argument("--start-date", type=_parse_date, help="inclusive backfill start date")
parser.add_argument("--end-date", type=_parse_date, help="inclusive backfill end date")
return parser
def main(argv: Sequence[str] | None = None) -> int:
"""Execute one build invocation and print a redacted JSON summary."""
args = build_parser().parse_args(argv)
if (args.start_date is None) != (args.end_date is None):
raise SystemExit("--start-date and --end-date must be provided together")
command = BuildSectorRadarCommand(
trade_date=args.trade_date,
start_date=args.start_date,
end_date=args.end_date,
retry_publication_id=args.retry_publication_id,
)
try:
settings = get_settings()
logging.basicConfig(
level=settings.log_level.upper(),
format="%(asctime)s %(levelname)s %(name)s %(message)s",
force=True,
)
logger.info(
"sector_radar_build_cli trade_date=%s start_date=%s end_date=%s retry=%s",
command.trade_date or "auto",
command.start_date or "none",
command.end_date or "none",
bool(command.retry_publication_id),
)
source = TushareSectorRadarAdapter.from_token(
settings.tushare_token,
max_retries=settings.sector_radar_max_retries,
backoff_seconds=settings.sector_radar_retry_backoff_seconds,
request_interval_seconds=settings.sector_radar_request_interval_seconds,
)
repository = PostgresSectorRadarRepository(
settings.database_url,
advisory_lock_key=settings.sector_radar_advisory_lock_key,
)
try:
summary = BuildSectorRadar(
source,
repository,
coverage_threshold=settings.sector_radar_coverage_threshold,
).execute(command)
finally:
repository.close()
except Exception as exc: # noqa: BLE001 - CLI boundary returns a redacted scheduler result
logger.error("sector_radar_build_initialization_failed error_type=%s", type(exc).__name__)
print(
json.dumps(
{
"status": "failed",
"exit_code": 1,
"outcomes": [],
"error_type": type(exc).__name__,
"error_message": "sector radar build initialization failed",
},
ensure_ascii=False,
sort_keys=True,
)
)
return 1
print(json.dumps(summary.as_dict(), ensure_ascii=False, sort_keys=True))
return summary.exit_code
def _parse_date(value: str) -> date:
try:
return date.fromisoformat(value)
except ValueError as exc:
raise argparse.ArgumentTypeError("date must use YYYY-MM-DD") from exc
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,302 @@
"""HTTP presentation for persisted sector radar rankings."""
from __future__ import annotations
import atexit
import threading
from datetime import date, datetime
from decimal import Decimal
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from ....bootstrap.config import Settings, get_settings
from ..application.read import (
RadarDateIndex,
RadarMetricDefinition,
RadarQuery,
RadarView,
RankingPage,
ReadSectorRadar,
)
from ..domain.models import (
MetricKind,
MetricQuality,
MetricUnit,
PublicationStatus,
RadarPublication,
RankedMetric,
RankSide,
SectorType,
)
from ..infrastructure.postgres import (
PostgresSectorRadarRepository,
SectorRadarRepositoryError,
)
sector_radar_router = APIRouter()
_REPOSITORY_CACHE_LOCK = threading.Lock()
_REPOSITORY_CACHE: dict[tuple[str, int], PostgresSectorRadarRepository] = {}
class RadarPublicationResponse(BaseModel):
"""Safe publication provenance and quality metadata."""
publication_id: str
target_trade_date: date
status: PublicationStatus
source_version: str
universe_version: str
metric_versions: list[str]
input_hash: str | None
coverage: Decimal = Field(ge=0, le=1)
started_at: datetime
finished_at: datetime | None
error_summary: str | None
class RadarDatesResponse(BaseModel):
"""Successful dates plus newest attempt and strict last-good metadata."""
status: Literal["success", "no_data"]
available_dates: list[date]
current_attempt: RadarPublicationResponse | None
last_good: RadarPublicationResponse | None
class RadarMetricDefinitionResponse(BaseModel):
"""Version and labeling for one independent metric implementation."""
metric_kind: MetricKind
metric_version: str
label: str
unit: MetricUnit
implementation_kind: Literal["independent"]
disclaimer: str
class RadarRankingRowResponse(BaseModel):
"""One sector ranking row with explicit null and unit semantics."""
trade_date: date
sector_type: SectorType
sector_code: str
sector_name: str
metric_kind: MetricKind
metric_version: str
implementation_kind: Literal["independent"]
unit: MetricUnit
metric_value: Decimal | None
quality: MetricQuality
member_count: int = Field(ge=0)
valid_sample_count: int = Field(ge=0)
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, gt=0, le=100)
rank_change_days: int = Field(ge=1, le=5)
rank_change: int | None
def _empty_ranking_rows() -> list[RadarRankingRowResponse]:
return []
class RadarRankingsResponse(BaseModel):
"""One persisted, filtered ranking page."""
status: Literal["success", "no_data"]
requested_trade_date: date | None
sector_type: SectorType
view: RadarView
rank_change_metric: MetricKind
rank_change_days: int = Field(ge=1, le=5)
side: RankSide
search: str | None
publication: RadarPublicationResponse | None
definition: RadarMetricDefinitionResponse
page: int = Field(ge=1)
page_size: int = Field(ge=1, le=100)
total: int = Field(ge=0)
rows: list[RadarRankingRowResponse] = Field(default_factory=_empty_ranking_rows)
def get_sector_radar_reader(
settings: Annotated[Settings, Depends(get_settings)],
) -> ReadSectorRadar:
"""Return a reader backed by one process-cached PostgreSQL repository."""
key = (settings.database_url, settings.sector_radar_advisory_lock_key)
with _REPOSITORY_CACHE_LOCK:
repository = _REPOSITORY_CACHE.get(key)
if repository is None:
repository = PostgresSectorRadarRepository(
settings.database_url,
advisory_lock_key=settings.sector_radar_advisory_lock_key,
)
_REPOSITORY_CACHE[key] = repository
return ReadSectorRadar(repository)
def _close_cached_repositories() -> None:
"""Close process-owned radar pools during interpreter shutdown."""
with _REPOSITORY_CACHE_LOCK:
repositories = tuple(_REPOSITORY_CACHE.values())
_REPOSITORY_CACHE.clear()
for repository in repositories:
repository.close()
atexit.register(_close_cached_repositories)
@sector_radar_router.get("/dates", response_model=RadarDatesResponse)
def get_sector_radar_dates(
reader: Annotated[ReadSectorRadar, Depends(get_sector_radar_reader)],
) -> RadarDatesResponse:
"""Return persisted availability without invoking Tushare."""
try:
return _dates_response(reader.list_dates())
except SectorRadarRepositoryError as exc:
raise _storage_error() from exc
@sector_radar_router.get("/rankings", response_model=RadarRankingsResponse)
def get_sector_radar_rankings(
reader: Annotated[ReadSectorRadar, Depends(get_sector_radar_reader)],
trade_date: date | None = None,
sector_type: SectorType = SectorType.CONCEPT,
view: RadarView = RadarView.AMOUNT,
rank_change_metric: MetricKind = MetricKind.AMOUNT,
rank_change_days: Annotated[int, Query(ge=1, le=5)] = 1,
side: RankSide = RankSide.ALL,
search: Annotated[str | None, Query(max_length=100)] = None,
page: Annotated[int, Query(ge=1)] = 1,
page_size: Annotated[int, Query(ge=1, le=100)] = 20,
) -> RadarRankingsResponse:
"""Return one filtered page from a successful publication."""
query = RadarQuery(
trade_date=trade_date,
sector_type=sector_type,
view=view,
rank_change_metric=rank_change_metric,
rank_change_days=rank_change_days,
side=side,
search=search.strip() or None if search else None,
page=page,
page_size=page_size,
)
try:
return _rankings_response(reader.query(query))
except SectorRadarRepositoryError as exc:
raise _storage_error() from exc
def _dates_response(index: RadarDateIndex) -> RadarDatesResponse:
return RadarDatesResponse(
status=index.status,
available_dates=list(index.available_dates),
current_attempt=(
_publication_response(index.current_attempt)
if index.current_attempt is not None
else None
),
last_good=(_publication_response(index.last_good) if index.last_good is not None else None),
)
def _rankings_response(page: RankingPage) -> RadarRankingsResponse:
query = page.query
return RadarRankingsResponse(
status=page.status,
requested_trade_date=query.trade_date,
sector_type=query.sector_type,
view=query.view,
rank_change_metric=query.rank_change_metric,
rank_change_days=query.rank_change_days,
side=query.side,
search=query.search,
publication=(
_publication_response(page.publication) if page.publication is not None else None
),
definition=_definition_response(page.definition),
page=query.page,
page_size=query.page_size,
total=page.total,
rows=[_ranking_response(row, query.rank_change_days) for row in page.rows],
)
def _publication_response(publication: RadarPublication) -> RadarPublicationResponse:
return RadarPublicationResponse(
publication_id=publication.publication_id,
target_trade_date=publication.target_trade_date,
status=publication.status,
source_version=publication.source_version,
universe_version=publication.universe_version,
metric_versions=list(publication.metric_versions),
input_hash=publication.input_hash,
coverage=publication.coverage,
started_at=publication.started_at,
finished_at=publication.finished_at,
error_summary=publication.error_summary,
)
def _definition_response(
definition: RadarMetricDefinition,
) -> RadarMetricDefinitionResponse:
return RadarMetricDefinitionResponse(
metric_kind=definition.metric_kind,
metric_version=definition.metric_version,
label=definition.label,
unit=definition.unit,
implementation_kind=definition.implementation_kind,
disclaimer=definition.disclaimer,
)
def _ranking_response(row: RankedMetric, rank_change_days: int) -> RadarRankingRowResponse:
observation = row.observation
return RadarRankingRowResponse(
trade_date=observation.trade_date,
sector_type=observation.sector_type,
sector_code=observation.sector_code,
sector_name=observation.sector_name,
metric_kind=observation.metric_kind,
metric_version=observation.metric_version,
implementation_kind=observation.implementation_kind,
unit=observation.unit,
metric_value=observation.value,
quality=observation.quality,
member_count=observation.member_count,
valid_sample_count=observation.valid_sample_count,
membership_coverage=observation.membership_coverage,
moneyflow_coverage=observation.moneyflow_coverage,
rank_position=row.rank_position,
rank_percentile=row.rank_percentile,
rank_change_days=rank_change_days,
rank_change=row.rank_change(rank_change_days),
)
def _storage_error() -> HTTPException:
return HTTPException(
status_code=503,
detail={
"code": "sector_radar_storage_unavailable",
"message": "sector radar storage is unavailable",
},
)
__all__ = [
"RadarDatesResponse",
"RadarRankingsResponse",
"get_sector_radar_reader",
"sector_radar_router",
]
@@ -0,0 +1,170 @@
"""Shared bounded retry and provider rate-limit coordination."""
from __future__ import annotations
import logging
import random
import threading
import time
from collections.abc import Callable, Sequence
logger = logging.getLogger(__name__)
DEFAULT_RATE_LIMIT_COOLDOWNS = (60.0, 120.0, 180.0)
_RATE_LIMIT_MESSAGES = (
"访问频繁",
"请稍后",
"超过频率",
"频率限制",
"too many requests",
"rate limit",
"rate_limit",
"http 429",
"status code: 429",
"429",
"http 403",
"status code: 403",
"403",
)
class TushareSourceError(RuntimeError):
"""A Tushare request failed after the configured retry budget."""
class RequestCoordinator:
"""Coordinate retries and shared rate-limit cooling for one provider client.
Normal requests are not serialized. Only a classified provider limit creates
a shared cooldown. Injectable time functions keep long cooldowns deterministic
in tests without coupling the coordinator to any business bounded context.
"""
def __init__(
self,
*,
max_retries: int = 3,
backoff_seconds: float = 1.0,
cooldown_seconds: Sequence[float] = DEFAULT_RATE_LIMIT_COOLDOWNS,
random_fn: Callable[[], float] = random.random,
clock: Callable[[], float] = time.monotonic,
wait_fn: Callable[[float], None] = time.sleep,
sleep_fn: Callable[[float], None] | None = None,
) -> None:
cooldowns = tuple(float(value) for value in cooldown_seconds)
if not cooldowns or any(value < 0 for value in cooldowns):
raise ValueError("cooldown_seconds must contain non-negative values")
self.max_retries = max(0, max_retries)
self.backoff_seconds = max(0.0, backoff_seconds)
self.cooldown_seconds = cooldowns
self.random_fn = random_fn
self.clock = clock
self.wait_fn = wait_fn
self.sleep_fn = sleep_fn or wait_fn
self._condition = threading.Condition()
self._cooldown_until = 0.0
self._rate_limit_count = 0
@property
def cooldown_until(self) -> float:
"""Return the current monotonic cooldown deadline."""
with self._condition:
return self._cooldown_until
def call(self, method_name: str, request: Callable[[], object]) -> object:
"""Execute one provider request with bounded, shared retry behavior."""
last_error: BaseException | None = None
for attempt in range(self.max_retries + 1):
self._wait_for_cooldown(method_name)
try:
result = request()
except Exception as exc:
last_error = exc
if self.is_rate_limited(exc):
cooldown = self._set_rate_limit_cooldown()
logger.warning(
"provider_rate_limit method=%s attempt=%d max_attempts=%d "
"cooldown_seconds=%.1f",
method_name,
attempt + 1,
self.max_retries + 1,
cooldown,
)
if attempt < self.max_retries:
continue
break
if not self._is_retryable(exc):
raise
if attempt == self.max_retries:
break
delay = self.backoff_seconds * (2**attempt) * (0.5 + self.random_fn())
logger.warning(
"provider_request_retry method=%s attempt=%d max_attempts=%d "
"backoff_seconds=%.1f",
method_name,
attempt + 1,
self.max_retries + 1,
delay,
)
self.sleep_fn(delay)
else:
self._clear_rate_limit_after_success()
return result
logger.error(
"provider_request_failed method=%s attempts=%d",
method_name,
self.max_retries + 1,
)
raise TushareSourceError(f"Tushare request failed: {method_name}") from last_error
def request(self, method_name: str, operation: Callable[[], object]) -> object:
"""Alias for ``call`` for adapters that model requests as a port."""
return self.call(method_name, operation)
def _wait_for_cooldown(self, method_name: str) -> None:
while True:
with self._condition:
delay = self._cooldown_until - self.clock()
if delay <= 0:
return
logger.info(
"provider_rate_limit_wait method=%s wait_seconds=%.1f",
method_name,
delay,
)
self.wait_fn(delay)
def _set_rate_limit_cooldown(self) -> float:
with self._condition:
self._rate_limit_count += 1
index = min(self._rate_limit_count - 1, len(self.cooldown_seconds) - 1)
duration = self.cooldown_seconds[index]
self._cooldown_until = max(self._cooldown_until, self.clock() + duration)
self._condition.notify_all()
return duration
def _clear_rate_limit_after_success(self) -> None:
with self._condition:
if self.clock() >= self._cooldown_until:
self._rate_limit_count = 0
@staticmethod
def is_rate_limited(error: BaseException) -> bool:
"""Classify stable provider rate-limit signals without logging details."""
for attribute in ("status_code", "status", "code"):
value = getattr(error, attribute, None)
if str(value).strip() in {"403", "429"}:
return True
message = str(error).casefold()
return any(marker.casefold() in message for marker in _RATE_LIMIT_MESSAGES)
@staticmethod
def _is_retryable(error: BaseException) -> bool:
return isinstance(error, (OSError, RuntimeError, TimeoutError))
TushareRequestCoordinator = RequestCoordinator
@@ -38,6 +38,13 @@ def test_postgres_migration_creates_market_data_contract(
"selection_run",
"selection_run_item",
"selection_signal",
"sector_radar_source_snapshot",
"sector_radar_membership",
"sector_radar_stock_fact",
"sector_radar_publication",
"sector_radar_ranking",
"sector_radar_daily_aggregate",
"sector_radar_publication_source",
} <= tables
finally:
engine.dispose()
@@ -0,0 +1,175 @@
import os
from dataclasses import replace
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
from pathlib import Path
import psycopg
import pytest
from alembic import command
from alembic.config import Config
from zhixing_server.bootstrap.config import sqlalchemy_database_url
from zhixing_server.modules.sector_radar.domain.models import (
MembershipStatus,
PublicationStatus,
RadarPublication,
SectorType,
StockFactStatus,
)
from zhixing_server.modules.sector_radar.domain.persistence import (
MembershipRecord,
StockFactRecord,
)
from zhixing_server.modules.sector_radar.domain.source import build_source_snapshot
from zhixing_server.modules.sector_radar.infrastructure.postgres import (
PostgresSectorRadarRepository,
)
TARGET_DATE = date(2099, 1, 4)
STARTED_AT = datetime(2099, 1, 4, 17, 30, tzinfo=UTC)
def prepare_database(database_url: str) -> None:
server_root = Path(__file__).parents[2]
config = Config(str(server_root / "alembic.ini"))
sqlalchemy_url = sqlalchemy_database_url(database_url)
config.set_main_option("sqlalchemy.url", sqlalchemy_url.replace("%", "%%"))
command.upgrade(config, "head")
@pytest.mark.integration
def test_postgres_sector_radar_revisions_and_last_good() -> None:
database_url = os.getenv("ZHIXING_TEST_DATABASE_URL")
if not database_url:
pytest.skip("set ZHIXING_TEST_DATABASE_URL to run PostgreSQL integration tests")
prepare_database(database_url)
snapshot = build_source_snapshot(
api_name="dc_member",
params={"trade_date": "20990104"},
rows=(
{
"trade_date": "20990104",
"ts_code": "BKTEST.DC",
"con_code": "000001.SZ",
"name": "测试股票",
},
),
target_trade_date=TARGET_DATE,
observed_at=STARTED_AT,
)
fact_revision = "b" * 64
publication_ids = ("test-sector-radar-success", "test-sector-radar-failed")
with psycopg.connect(database_url) as connection, connection.transaction():
connection.execute(
"DELETE FROM sector_radar_publication WHERE id = ANY(%s)",
(list(publication_ids),),
)
connection.execute(
"DELETE FROM sector_radar_stock_fact WHERE fact_revision = %s",
(fact_revision,),
)
connection.execute(
"DELETE FROM sector_radar_source_snapshot WHERE id = %s",
(snapshot.snapshot_id,),
)
repository = PostgresSectorRadarRepository(database_url, max_connections=2)
try:
assert repository.save_source_snapshots((snapshot,)).inserted == 1
assert (
repository.save_source_snapshots(
(replace(snapshot, observed_at=STARTED_AT + timedelta(minutes=1)),)
).unchanged
== 1
)
assert (
repository.save_memberships(
(
MembershipRecord(
source_snapshot_id=snapshot.snapshot_id,
trade_date=TARGET_DATE,
sector_type=SectorType.CONCEPT,
sector_code="BKTEST.DC",
sector_name="测试概念",
stock_code="000001.SZ",
stock_name="测试股票",
status=MembershipStatus.AVAILABLE,
),
)
).inserted
== 1
)
assert (
repository.save_stock_facts(
(
StockFactRecord(
fact_revision=fact_revision,
source_snapshot_ids=(snapshot.snapshot_id,),
trade_date=TARGET_DATE,
ts_code="000001.SZ",
status=StockFactStatus.AVAILABLE,
turnover_yuan=Decimal("1000"),
net_amount_yuan=Decimal("100"),
),
)
).inserted
== 1
)
running = RadarPublication(
publication_id=publication_ids[0],
target_trade_date=TARGET_DATE,
status=PublicationStatus.RUNNING,
source_version="tushare-pro-v1",
universe_version=snapshot.content_sha256,
metric_versions=("zhixing_amount_net_bn_v1",),
input_hash=None,
coverage=Decimal(0),
started_at=STARTED_AT,
)
repository.create_publication(running)
repository.finish_publication(
replace(
running,
status=PublicationStatus.SUCCESS,
input_hash="a" * 64,
coverage=Decimal(1),
finished_at=STARTED_AT + timedelta(minutes=5),
)
)
failed = replace(
running,
publication_id=publication_ids[1],
started_at=STARTED_AT + timedelta(minutes=6),
)
repository.create_publication(failed)
repository.finish_publication(
replace(
failed,
status=PublicationStatus.FAILED,
coverage=Decimal("0.8"),
finished_at=STARTED_AT + timedelta(minutes=7),
error_summary="safe_error",
)
)
last_good = repository.get_last_good_publication(TARGET_DATE)
assert last_good is not None
assert last_good.publication_id == publication_ids[0]
finally:
repository.close()
with psycopg.connect(database_url) as connection, connection.transaction():
connection.execute(
"DELETE FROM sector_radar_publication WHERE id = ANY(%s)",
(list(publication_ids),),
)
connection.execute(
"DELETE FROM sector_radar_stock_fact WHERE fact_revision = %s",
(fact_revision,),
)
connection.execute(
"DELETE FROM sector_radar_source_snapshot WHERE id = %s",
(snapshot.snapshot_id,),
)
@@ -0,0 +1,230 @@
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 (
RadarDateIndex,
RadarMetricDefinition,
RadarQuery,
RadarView,
RankingPage,
)
from zhixing_server.modules.sector_radar.domain.metrics import AmountNetStrategy
from zhixing_server.modules.sector_radar.domain.models import (
MetricKind,
MetricObservation,
MetricQuality,
MetricUnit,
PublicationStatus,
RadarPublication,
RankChange,
RankedMetric,
RankSide,
SectorType,
)
from zhixing_server.modules.sector_radar.infrastructure.postgres import (
SectorRadarRepositoryError,
)
from zhixing_server.modules.sector_radar.presentation.http import (
RadarRankingRowResponse,
get_sector_radar_reader,
)
TARGET_DATE = date(2026, 8, 28)
NOW = datetime(2026, 8, 28, 17, 30, tzinfo=UTC)
def _publication(
publication_id: str,
status: PublicationStatus,
*,
trade_date: date = TARGET_DATE,
) -> RadarPublication:
return RadarPublication(
publication_id=publication_id,
target_trade_date=trade_date,
status=status,
source_version="tushare-pro-v1",
universe_version="eastmoney-dc-v1",
metric_versions=(AmountNetStrategy.metric_version,),
input_hash="a" * 64 if status is PublicationStatus.SUCCESS else None,
coverage=Decimal(1) if status is PublicationStatus.SUCCESS else Decimal("0.8"),
started_at=NOW,
finished_at=None if status is PublicationStatus.RUNNING else NOW + timedelta(minutes=5),
error_summary=None if status is PublicationStatus.SUCCESS else "safe_error",
)
def _ranking() -> RankedMetric:
return RankedMetric(
observation=MetricObservation(
trade_date=TARGET_DATE,
sector_type=SectorType.CONCEPT,
sector_code="BK0001.DC",
sector_name="机器人",
metric_kind=MetricKind.AMOUNT,
metric_version=AmountNetStrategy.metric_version,
implementation_kind="independent",
unit=MetricUnit.CNY_100M,
value=Decimal("12.5"),
quality=MetricQuality.AVAILABLE,
member_count=20,
valid_sample_count=19,
membership_coverage=Decimal(1),
moneyflow_coverage=Decimal("0.95"),
),
rank_position=1,
rank_percentile=Decimal(100),
rank_changes=(RankChange(days=5, value=3),),
)
class FakeReader:
def __init__(self, *, no_data: bool = False, fail: bool = False) -> None:
self.fail = fail
self.last_query: RadarQuery | None = None
success = _publication("publication-success", PublicationStatus.SUCCESS)
current = _publication(
"publication-partial",
PublicationStatus.PARTIAL,
trade_date=TARGET_DATE + timedelta(days=1),
)
self.date_index = RadarDateIndex(
available_dates=() if no_data else (TARGET_DATE,),
current_attempt=None if no_data else current,
last_good=None if no_data else success,
)
query = RadarQuery()
self.page = RankingPage(
status="no_data" if no_data else "success",
query=query,
publication=None if no_data else success,
definition=RadarMetricDefinition(
metric_kind=MetricKind.AMOUNT,
metric_version=AmountNetStrategy.metric_version,
label="主力净流入(知行独立实现)",
unit=MetricUnit.CNY_100M,
),
rows=() if no_data else (_ranking(),),
total=0 if no_data else 1,
)
def list_dates(self) -> RadarDateIndex:
if self.fail:
raise SectorRadarRepositoryError("private database detail")
return self.date_index
def query(self, query: RadarQuery) -> RankingPage:
if self.fail:
raise SectorRadarRepositoryError("private database detail")
self.last_query = query
return replace(self.page, query=query)
def _client(reader: FakeReader) -> TestClient:
application = create_app()
application.dependency_overrides[get_sector_radar_reader] = lambda: reader
return TestClient(application)
def test_dates_exposes_partial_attempt_without_replacing_last_good() -> None:
response = _client(FakeReader()).get("/api/v1/sector-radar/dates")
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "success"
assert payload["available_dates"] == ["2026-08-28"]
assert payload["current_attempt"]["status"] == "partial"
assert payload["last_good"]["status"] == "success"
assert payload["last_good"]["coverage"] == "1"
def test_rankings_maps_filters_and_independent_metric_contract() -> None:
reader = FakeReader()
response = _client(reader).get(
"/api/v1/sector-radar/rankings",
params={
"trade_date": "2026-08-28",
"sector_type": "concept",
"view": "rank_change",
"rank_change_metric": "amount",
"rank_change_days": 5,
"side": "top",
"search": " 机器人 ",
"page": 2,
"page_size": 10,
},
)
assert response.status_code == 200
assert reader.last_query == RadarQuery(
trade_date=TARGET_DATE,
sector_type=SectorType.CONCEPT,
view=RadarView.RANK_CHANGE,
rank_change_metric=MetricKind.AMOUNT,
rank_change_days=5,
side=RankSide.TOP,
search="机器人",
page=2,
page_size=10,
)
payload = response.json()
assert payload["definition"]["metric_version"] == "zhixing_amount_net_bn_v1"
assert payload["definition"]["implementation_kind"] == "independent"
assert "知行独立实现" in payload["definition"]["disclaimer"]
assert payload["rows"][0]["rank_change"] == 3
assert payload["rows"][0]["unit"] == "CNY_100M"
def test_no_data_is_a_stable_200_response() -> None:
client = _client(FakeReader(no_data=True))
dates = client.get("/api/v1/sector-radar/dates")
rankings = client.get("/api/v1/sector-radar/rankings")
assert dates.status_code == 200
assert dates.json()["status"] == "no_data"
assert rankings.status_code == 200
assert rankings.json()["status"] == "no_data"
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())
for params in (
{"rank_change_days": 0},
{"rank_change_days": 6},
{"page": 0},
{"page_size": 101},
{"sector_type": "region"},
{"view": "unknown"},
{"side": "unknown"},
):
assert client.get("/api/v1/sector-radar/rankings", params=params).status_code == 422
def test_repository_error_maps_to_redacted_503() -> None:
response = _client(FakeReader(fail=True)).get("/api/v1/sector-radar/rankings")
assert response.status_code == 503
assert response.json() == {
"detail": {
"code": "sector_radar_storage_unavailable",
"message": "sector radar storage is unavailable",
}
}
assert "private database detail" not in response.text
@@ -0,0 +1,498 @@
from collections.abc import Sequence
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
from zhixing_server.modules.sector_radar.application.build import (
BuildSectorRadar,
BuildSectorRadarCommand,
)
from zhixing_server.modules.sector_radar.domain.models import (
MembershipStatus,
PublicationStatus,
RadarPublication,
SectorType,
)
from zhixing_server.modules.sector_radar.domain.persistence import PublicationSourceGroup
from zhixing_server.modules.sector_radar.domain.source import (
CapabilityProbeResult,
DailyRow,
MoneyflowDcRow,
SectorIndexRow,
SectorMemberRow,
SourceResult,
SourceSnapshot,
StockBasicRow,
SuspendRow,
TradeCalendarRow,
build_source_snapshot,
)
from zhixing_server.modules.sector_radar.infrastructure.memory import (
InMemorySectorRadarRepository,
)
TARGET_DATE = date(2026, 8, 28)
NOW = datetime(2026, 8, 28, 17, 30, tzinfo=UTC)
class FakeRadarSource:
def __init__(
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] = []
def _result[T](
self, api_name: str, target: date | None, rows: tuple[T, ...]
) -> SourceResult[T]:
snapshot = build_source_snapshot(
api_name=api_name,
params={
"trade_date": target.isoformat() if target is not None else "all",
"fixture_fingerprint": repr(rows),
},
rows=tuple(self._raw_row(row) for row in rows),
target_trade_date=target,
partition_key="all" if api_name == "dc_member" else None,
observed_at=NOW,
)
return SourceResult((snapshot,), rows)
@staticmethod
def _raw_row(row: object) -> dict[str, object]:
if isinstance(row, TradeCalendarRow):
return {
"exchange": row.exchange,
"cal_date": row.cal_date,
"is_open": int(row.is_open),
"pretrade_date": row.pretrade_date,
}
if isinstance(row, SectorIndexRow):
return {
"trade_date": row.trade_date,
"ts_code": row.sector_code,
"name": row.name,
"level": row.level,
"pct_change": row.pct_change,
"leading_code": row.leading_code,
}
if isinstance(row, SectorMemberRow):
return {
"trade_date": row.trade_date,
"ts_code": row.sector_code,
"con_code": row.stock_code,
"name": row.stock_name,
}
if isinstance(row, StockBasicRow):
return {
"ts_code": row.ts_code,
"symbol": row.symbol,
"name": row.name,
"market": row.market,
"exchange": row.exchange,
"list_status": row.list_status,
"list_date": row.list_date,
"delist_date": row.delist_date,
}
if isinstance(row, SuspendRow):
return {
"ts_code": row.ts_code,
"trade_date": row.trade_date,
"suspend_timing": row.suspend_timing,
"suspend_type": row.suspend_type,
}
if isinstance(row, DailyRow):
return {
"ts_code": row.ts_code,
"trade_date": row.trade_date,
"close": row.close,
"pre_close": row.pre_close,
"pct_chg": row.pct_chg,
"vol": row.volume,
"amount": row.amount_thousand_yuan,
}
if isinstance(row, MoneyflowDcRow):
return {
"trade_date": row.trade_date,
"ts_code": row.ts_code,
"name": row.name,
"net_amount": row.net_amount_ten_thousand_yuan,
"net_amount_rate": row.net_amount_rate,
"pct_change": row.pct_change,
"close": row.close,
}
raise TypeError(f"unsupported fake source row: {type(row).__name__}")
def fetch_trade_calendar(self, start: date, end: date) -> SourceResult[TradeCalendarRow]:
self.calls.append("calendar")
rows = tuple(
TradeCalendarRow("SSE", start + timedelta(days=offset), True, None)
for offset in range((end - start).days + 1)
)
return self._result("trade_cal", end, rows)
def fetch_sector_indices(
self, trade_date: date, sector_type: SectorType
) -> SourceResult[SectorIndexRow]:
self.calls.append(f"{sector_type.value}_indices")
prefix = "BK0" if sector_type is SectorType.CONCEPT else "BK1"
row = SectorIndexRow(
trade_date,
sector_type,
f"{prefix}001.DC",
"示例概念" if sector_type is SectorType.CONCEPT else "示例行业",
"一级",
Decimal(1),
"000001.SZ",
)
return self._result(f"dc_index_{sector_type.value}", trade_date, (row,))
def fetch_sector_members(
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
for index in range(1, 6)
)
return self._result("dc_member", trade_date, rows)
def fetch_stock_basics(self) -> SourceResult[StockBasicRow]:
self.calls.append("stock_basics")
rows = tuple(
StockBasicRow(
f"00000{index}.SZ",
f"00000{index}",
f"股票{index}",
"主板",
"SZSE",
"L",
date(2020, 1, 1),
None,
)
for index in range(1, 6)
)
return self._result("stock_basic", None, rows)
def fetch_suspensions(self, trade_date: date) -> SourceResult[SuspendRow]:
self.calls.append("suspensions")
return self._result("suspend_d", trade_date, ())
def fetch_daily(self, trade_date: date) -> SourceResult[DailyRow]:
self.calls.append("daily")
if self.fail_daily:
raise RuntimeError("private provider detail")
rows = tuple(
DailyRow(
f"00000{index}.SZ",
trade_date,
Decimal(10),
Decimal(10),
Decimal(0),
Decimal(100),
Decimal(1000),
)
for index in range(1, 6)
)
return self._result("daily", trade_date, rows)
def fetch_moneyflow_dc(self, trade_date: date) -> SourceResult[MoneyflowDcRow]:
self.calls.append("moneyflow_dc")
count = 4 if self.missing_moneyflow else 5
rows = tuple(
MoneyflowDcRow(
trade_date,
f"00000{index}.SZ",
f"股票{index}",
Decimal(index) * self.net_scale,
Decimal(0),
Decimal(0),
Decimal(10),
)
for index in range(1, count + 1)
)
return self._result("moneyflow_dc", trade_date, rows)
def probe(self, trade_date: date) -> CapabilityProbeResult:
return CapabilityProbeResult(NOW, ())
def test_successful_build_is_idempotent_and_failed_retry_preserves_last_good() -> None:
source = FakeRadarSource()
repository = InMemorySectorRadarRepository()
use_case = BuildSectorRadar(
source,
repository,
today=TARGET_DATE,
now_fn=lambda: NOW,
)
first = use_case.execute(BuildSectorRadarCommand(trade_date=TARGET_DATE))
repeated = use_case.execute(BuildSectorRadarCommand(trade_date=TARGET_DATE))
source.fail_daily = True
failed = use_case.execute(BuildSectorRadarCommand(trade_date=TARGET_DATE))
assert first.status == "success"
assert first.exit_code == 0
assert first.outcomes[0].ranking_count == 6
assert repeated.status == "unchanged"
assert repeated.outcomes[0].publication_id == first.outcomes[0].publication_id
assert failed.status == "failed"
assert "private provider detail" not in str(failed.as_dict())
last_good = repository.get_last_good_publication()
assert last_good is not None
assert last_good.publication_id == first.outcomes[0].publication_id
assert any(item.status is PublicationStatus.FAILED for item in repository.publications.values())
def test_partial_coverage_and_lock_have_distinct_exit_codes() -> None:
repository = InMemorySectorRadarRepository()
partial = BuildSectorRadar(
FakeRadarSource(missing_moneyflow=True),
repository,
today=TARGET_DATE,
now_fn=lambda: NOW,
).execute(BuildSectorRadarCommand(trade_date=TARGET_DATE))
publication_count = len(repository.publications)
partial_repeated = BuildSectorRadar(
FakeRadarSource(missing_moneyflow=True),
repository,
today=TARGET_DATE,
now_fn=lambda: NOW,
).execute(BuildSectorRadarCommand(trade_date=TARGET_DATE))
repository.lock_available = False
locked = BuildSectorRadar(
FakeRadarSource(),
repository,
today=TARGET_DATE,
now_fn=lambda: NOW,
).execute(BuildSectorRadarCommand(trade_date=TARGET_DATE))
assert partial.status == "partial"
assert partial.exit_code == 2
assert partial.outcomes[0].coverage == Decimal("0.8")
assert partial_repeated.status == "partial"
assert len(repository.publications) == publication_count
assert repository.get_last_good_publication() is None
assert {
record.source_group
for record in repository.load_publication_sources(partial.outcomes[0].publication_id or "")
if record.refresh_on_retry
} == {PublicationSourceGroup.MONEYFLOW_DC}
assert locked.status == "failed"
assert locked.exit_code == 1
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)
use_case = BuildSectorRadar(source, repository, now_fn=lambda: NOW)
end = TARGET_DATE + timedelta(days=1)
summary = use_case.execute(BuildSectorRadarCommand(start_date=TARGET_DATE, end_date=end))
partial_id = summary.outcomes[0].publication_id
assert partial_id is not None
source.missing_moneyflow = False
source.calls.clear()
retried = use_case.execute(BuildSectorRadarCommand(retry_publication_id=partial_id))
assert [item.target_trade_date for item in summary.outcomes] == [TARGET_DATE, end]
assert all(item.status == "partial" for item in summary.outcomes)
assert retried.outcomes[0].target_trade_date == TARGET_DATE
assert retried.status == "success"
assert source.calls == ["moneyflow_dc"]
def test_failed_retry_reuses_every_completed_source_group() -> None:
repository = InMemorySectorRadarRepository()
source = FakeRadarSource()
source.fail_daily = True
use_case = BuildSectorRadar(source, repository, now_fn=lambda: NOW)
failed = use_case.execute(BuildSectorRadarCommand(trade_date=TARGET_DATE))
failed_id = failed.outcomes[0].publication_id
assert failed_id is not None
source.fail_daily = False
source.calls.clear()
retried = use_case.execute(BuildSectorRadarCommand(retry_publication_id=failed_id))
assert retried.status == "success"
assert source.calls == ["daily", "moneyflow_dc"]
old_groups = {record.source_group for record in repository.load_publication_sources(failed_id)}
assert len(old_groups) == 6
def test_date_lock_recovers_an_orphaned_running_publication() -> None:
repository = InMemorySectorRadarRepository()
stale = RadarPublication(
publication_id="stale-running",
target_trade_date=TARGET_DATE,
status=PublicationStatus.RUNNING,
source_version="tushare-pro-v1",
universe_version="pending",
metric_versions=("zhixing_amount_net_bn_v1",),
input_hash=None,
coverage=Decimal(0),
started_at=NOW - timedelta(hours=1),
)
repository.create_publication(stale)
summary = BuildSectorRadar(FakeRadarSource(), repository, now_fn=lambda: NOW).execute(
BuildSectorRadarCommand(trade_date=TARGET_DATE)
)
recovered = repository.get_publication("stale-running")
assert summary.status == "success"
assert recovered is not None
assert recovered.status is PublicationStatus.FAILED
assert recovered.error_summary == "recovered_stale_running"
def test_default_target_excludes_today_before_closing_data_is_ready() -> None:
before_close = datetime(2026, 8, 28, 6, 0, tzinfo=UTC)
after_close = datetime(2026, 8, 28, 8, 0, tzinfo=UTC)
before = BuildSectorRadar(
FakeRadarSource(),
InMemorySectorRadarRepository(),
today=TARGET_DATE,
now_fn=lambda: before_close,
).execute()
after = BuildSectorRadar(
FakeRadarSource(),
InMemorySectorRadarRepository(),
today=TARGET_DATE,
now_fn=lambda: after_close,
).execute()
assert before.outcomes[0].target_trade_date == TARGET_DATE - timedelta(days=1)
assert after.outcomes[0].target_trade_date == TARGET_DATE
def test_tenth_trading_day_publishes_swing_and_five_rank_changes() -> None:
repository = InMemorySectorRadarRepository()
end = TARGET_DATE + timedelta(days=9)
summary = BuildSectorRadar(FakeRadarSource(), repository, now_fn=lambda: NOW).execute(
BuildSectorRadarCommand(start_date=TARGET_DATE, end_date=end)
)
publication = repository.get_last_good_publication(end)
assert summary.status == "success"
assert publication is not None
current = tuple(
record.ranking
for record in repository.rankings.values()
if record.publication_id == publication.publication_id
)
swing = tuple(
ranking
for ranking in current
if ranking.observation.metric_version == "zhixing_swing_equal_3_10_v1"
)
assert len(swing) == 2
assert all(ranking.observation.value == Decimal("0.03") for ranking in swing)
assert all(
tuple(change.value for change in ranking.rank_changes) == (None, None, None, None, None)
for ranking in swing
)
amount = tuple(
ranking
for ranking in current
if ranking.observation.metric_version == "zhixing_amount_net_bn_v1"
)
assert all(
tuple(change.value for change in ranking.rank_changes) == (0, 0, 0, 0, 0)
for ranking in amount
)
def test_history_uses_latest_successful_input_revision_for_a_date() -> None:
repository = InMemorySectorRadarRepository()
clock = [NOW]
first_source = FakeRadarSource(net_scale=Decimal(1))
second_source = FakeRadarSource(net_scale=Decimal(2))
first = BuildSectorRadar(first_source, repository, now_fn=lambda: clock[0]).execute(
BuildSectorRadarCommand(trade_date=TARGET_DATE)
)
clock[0] = NOW + timedelta(minutes=5)
second = BuildSectorRadar(second_source, repository, now_fn=lambda: clock[0]).execute(
BuildSectorRadarCommand(trade_date=TARGET_DATE)
)
history = repository.load_daily_aggregate_history(
TARGET_DATE + timedelta(days=1), limit_dates=1
)
assert first.status == "success"
assert second.status == "success"
assert len(history) == 2
assert all(item.net_amount_yuan == Decimal(300_000) for item in history)
@@ -0,0 +1,127 @@
import json
from datetime import date
from decimal import Decimal
import pytest
from zhixing_server.modules.sector_radar.application.build import (
BuildDateOutcome,
BuildOutcomeStatus,
BuildSummary,
)
from zhixing_server.modules.sector_radar.presentation import cli
from zhixing_server.modules.sector_radar.presentation.cli import build_parser
def test_sector_radar_cli_parses_single_range_and_retry_modes() -> None:
parser = build_parser()
single = parser.parse_args(["--trade-date", "2026-08-28"])
date_range = parser.parse_args(["--start-date", "2026-08-18", "--end-date", "2026-08-28"])
retry = parser.parse_args(["--retry-publication-id", "publication-a"])
assert single.trade_date == date(2026, 8, 28)
assert date_range.start_date == date(2026, 8, 18)
assert date_range.end_date == date(2026, 8, 28)
assert retry.retry_publication_id == "publication-a"
class FakeSettings:
log_level = "INFO"
tushare_token = "secret-token"
database_url = "postgresql://unused"
sector_radar_max_retries = 3
sector_radar_retry_backoff_seconds = 1.0
sector_radar_request_interval_seconds = 0.2
sector_radar_advisory_lock_key = 7_380_522
sector_radar_coverage_threshold = Decimal("0.99")
class FakeRepository:
closed = False
def __init__(self, database_url: str, *, advisory_lock_key: int) -> None:
assert database_url == "postgresql://unused"
assert advisory_lock_key == 7_380_522
def close(self) -> None:
self.closed = True
@pytest.mark.parametrize(
("outcome_status", "coverage", "expected_code"),
(("success", Decimal(1), 0), ("partial", Decimal("0.8"), 2), ("failed", Decimal(0), 1)),
)
def test_cli_main_returns_summary_exit_code_and_json(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
outcome_status: BuildOutcomeStatus,
coverage: Decimal,
expected_code: int,
) -> None:
summary = BuildSummary(
(
BuildDateOutcome(
date(2026, 8, 28),
outcome_status,
"publication-a",
coverage,
2,
6,
),
)
)
class FakeSourceFactory:
@staticmethod
def from_token(token: str, **kwargs: object) -> object:
assert token == "secret-token"
assert kwargs
return object()
class FakeBuild:
def __init__(self, source: object, repository: object, **kwargs: object) -> None:
assert source is not None
assert repository is not None
assert kwargs
def execute(self, command: object) -> BuildSummary:
assert command is not None
return summary
monkeypatch.setattr(cli, "get_settings", FakeSettings)
monkeypatch.setattr(cli, "TushareSectorRadarAdapter", FakeSourceFactory)
monkeypatch.setattr(cli, "PostgresSectorRadarRepository", FakeRepository)
monkeypatch.setattr(cli, "BuildSectorRadar", FakeBuild)
exit_code = cli.main(["--trade-date", "2026-08-28"])
output = json.loads(capsys.readouterr().out)
assert exit_code == expected_code
assert output["status"] == summary.status
assert output["exit_code"] == expected_code
assert "secret-token" not in str(output)
def test_cli_initialization_failure_is_redacted(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
class FailingSourceFactory:
@staticmethod
def from_token(token: str, **kwargs: object) -> object:
del token, kwargs
raise RuntimeError("private provider detail secret-token")
monkeypatch.setattr(cli, "get_settings", FakeSettings)
monkeypatch.setattr(cli, "TushareSectorRadarAdapter", FailingSourceFactory)
exit_code = cli.main(["--trade-date", "2026-08-28"])
captured = capsys.readouterr()
output = json.loads(captured.out)
assert exit_code == 1
assert output["status"] == "failed"
assert output["error_type"] == "RuntimeError"
assert "private provider detail" not in captured.out
assert "secret-token" not in captured.out
@@ -0,0 +1,146 @@
from datetime import UTC, date, datetime
from decimal import Decimal
import pytest
from zhixing_server.modules.sector_radar.domain.facts import aggregate_sector_snapshot
from zhixing_server.modules.sector_radar.domain.models import (
MembershipStatus,
PublicationStatus,
RadarPublication,
SectorMembershipSnapshot,
SectorType,
StockDailyFact,
StockFactStatus,
)
TARGET_DATE = date(2026, 8, 28)
def test_point_in_time_aggregation_distinguishes_suspension_missing_and_zero() -> None:
snapshot = SectorMembershipSnapshot(
trade_date=TARGET_DATE,
sector_type=SectorType.CONCEPT,
sector_code="BK0001.DC",
sector_name="示例概念",
member_codes=("000001.SZ", "000002.SZ", "000003.SZ", "000004.SZ"),
status=MembershipStatus.AVAILABLE,
source_version="dc-member-20260828-a",
)
facts = (
StockDailyFact(
trade_date=TARGET_DATE,
ts_code="000001.SZ",
status=StockFactStatus.AVAILABLE,
turnover_yuan=Decimal("1000"),
net_amount_yuan=Decimal("100"),
),
StockDailyFact(
trade_date=TARGET_DATE,
ts_code="000002.SZ",
status=StockFactStatus.AVAILABLE,
turnover_yuan=Decimal("2000"),
net_amount_yuan=Decimal("0"),
),
StockDailyFact(
trade_date=TARGET_DATE,
ts_code="000003.SZ",
status=StockFactStatus.SUSPENDED,
),
StockDailyFact(
trade_date=TARGET_DATE,
ts_code="000004.SZ",
status=StockFactStatus.MISSING_MONEYFLOW,
),
)
aggregate = aggregate_sector_snapshot(snapshot, facts)
assert aggregate.member_count == 4
assert aggregate.valid_sample_count == 2
assert aggregate.net_amount_yuan == Decimal("100")
assert aggregate.turnover_yuan == Decimal("3000")
assert aggregate.membership_coverage == Decimal("1")
assert aggregate.moneyflow_coverage == Decimal("2") / Decimal("3")
def test_unknown_membership_never_falls_back_to_available_stock_facts() -> None:
snapshot = SectorMembershipSnapshot(
trade_date=TARGET_DATE,
sector_type=SectorType.INDUSTRY,
sector_code="BK1001.DC",
sector_name="示例行业",
member_codes=(),
status=MembershipStatus.UNKNOWN,
source_version="dc-member-missing",
)
fact = StockDailyFact(
trade_date=TARGET_DATE,
ts_code="000001.SZ",
status=StockFactStatus.AVAILABLE,
turnover_yuan=Decimal("1000"),
net_amount_yuan=Decimal("100"),
)
aggregate = aggregate_sector_snapshot(snapshot, (fact,))
assert aggregate.member_count == 0
assert aggregate.net_amount_yuan is None
assert aggregate.turnover_yuan is None
assert aggregate.membership_coverage == Decimal("0")
def test_stock_fact_rejects_non_finite_values_and_invalid_status_payloads() -> None:
with pytest.raises(ValueError, match="finite"):
StockDailyFact(
trade_date=TARGET_DATE,
ts_code="000001.SZ",
status=StockFactStatus.AVAILABLE,
turnover_yuan=Decimal("Infinity"),
net_amount_yuan=Decimal("1"),
)
with pytest.raises(ValueError, match="must not expose amounts"):
StockDailyFact(
trade_date=TARGET_DATE,
ts_code="000001.SZ",
status=StockFactStatus.SUSPENDED,
turnover_yuan=Decimal("0"),
)
def test_publication_requires_terminal_completion_and_replay_identity() -> None:
started_at = datetime(2026, 8, 28, 17, 30, tzinfo=UTC)
publication = RadarPublication(
publication_id="radar-20260828-a",
target_trade_date=TARGET_DATE,
status=PublicationStatus.SUCCESS,
source_version="tushare-pro-v1",
universe_version="eastmoney-dc-20260828-a",
metric_versions=(
"zhixing_amount_net_bn_v1",
"zhixing_ratio_turnover_v1",
"zhixing_swing_equal_3_10_v1",
),
input_hash="a" * 64,
coverage=Decimal("0.995"),
started_at=started_at,
finished_at=datetime(2026, 8, 28, 17, 35, tzinfo=UTC),
)
assert publication.status is PublicationStatus.SUCCESS
with pytest.raises(ValueError, match="finished_at"):
RadarPublication(
publication_id="radar-20260828-running",
target_trade_date=TARGET_DATE,
status=PublicationStatus.RUNNING,
source_version="tushare-pro-v1",
universe_version="eastmoney-dc-20260828-a",
metric_versions=("zhixing_amount_net_bn_v1",),
input_hash=None,
coverage=Decimal("0"),
started_at=started_at,
finished_at=started_at,
)
@@ -0,0 +1,112 @@
from datetime import date
from decimal import Decimal
from zhixing_server.modules.sector_radar.domain.metrics import (
AmountNetStrategy,
RatioTurnoverStrategy,
SwingEqualThreeToTenStrategy,
)
from zhixing_server.modules.sector_radar.domain.models import (
MetricQuality,
SectorDailyAggregate,
SectorType,
)
TARGET_DATE = date(2026, 8, 28)
def make_aggregate(
*,
net_amount_yuan: Decimal | None = Decimal("125000000"),
turnover_yuan: Decimal | None = Decimal("5000000000"),
) -> SectorDailyAggregate:
return SectorDailyAggregate(
trade_date=TARGET_DATE,
sector_type=SectorType.CONCEPT,
sector_code="BK0001.DC",
sector_name="示例概念",
member_count=10,
valid_sample_count=10,
net_amount_yuan=net_amount_yuan,
turnover_yuan=turnover_yuan,
membership_coverage=Decimal("1"),
moneyflow_coverage=Decimal("1"),
)
def test_amount_and_ratio_strategies_expose_independent_versioned_values() -> None:
aggregate = make_aggregate()
amount = AmountNetStrategy().evaluate((aggregate,), TARGET_DATE)
ratio = RatioTurnoverStrategy().evaluate((aggregate,), TARGET_DATE)
assert amount.value == Decimal("1.25")
assert amount.metric_version == "zhixing_amount_net_bn_v1"
assert amount.implementation_kind == "independent"
assert amount.unit == "CNY_100M"
assert amount.quality is MetricQuality.AVAILABLE
assert ratio.value == Decimal("0.025")
assert ratio.metric_version == "zhixing_ratio_turnover_v1"
assert ratio.implementation_kind == "independent"
assert ratio.unit == "ratio"
def test_missing_moneyflow_is_unavailable_but_zero_remains_a_real_value() -> None:
missing = AmountNetStrategy().evaluate((make_aggregate(net_amount_yuan=None),), TARGET_DATE)
zero = AmountNetStrategy().evaluate(
(make_aggregate(net_amount_yuan=Decimal("0")),), TARGET_DATE
)
assert missing.value is None
assert missing.quality is MetricQuality.UNAVAILABLE
assert zero.value == Decimal("0")
assert zero.quality is MetricQuality.AVAILABLE
def test_swing_strategy_uses_each_days_point_in_time_aggregate() -> None:
history = tuple(
SectorDailyAggregate(
trade_date=date(2026, 8, 18 + offset),
sector_type=SectorType.CONCEPT,
sector_code="BK0001.DC",
sector_name="示例概念",
member_count=6 + offset,
valid_sample_count=6 + offset,
net_amount_yuan=Decimal(str(offset + 1)),
turnover_yuan=Decimal("100"),
membership_coverage=Decimal("1"),
moneyflow_coverage=Decimal("1"),
)
for offset in range(10)
)
result = SwingEqualThreeToTenStrategy().evaluate(history, date(2026, 8, 27))
# The worked 3..10-day window ratios average to exactly 0.0725.
assert result.value == Decimal("0.0725")
assert result.metric_version == "zhixing_swing_equal_3_10_v1"
assert result.member_count == 15
def test_swing_strategy_carries_forward_limited_historical_sample_quality() -> None:
history = tuple(
SectorDailyAggregate(
trade_date=date(2026, 8, 18 + offset),
sector_type=SectorType.INDUSTRY,
sector_code="BK1001.DC",
sector_name="示例行业",
member_count=10,
valid_sample_count=4 if offset == 0 else 10,
net_amount_yuan=Decimal("10"),
turnover_yuan=Decimal("100"),
membership_coverage=Decimal("1"),
moneyflow_coverage=Decimal("1"),
)
for offset in range(10)
)
result = SwingEqualThreeToTenStrategy().evaluate(history, date(2026, 8, 27))
assert result.value == Decimal("0.1")
assert result.quality is MetricQuality.AVAILABLE_LIMITED_SAMPLE
@@ -0,0 +1,188 @@
from datetime import UTC, date, datetime
from decimal import Decimal
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,
build_source_snapshot,
)
TARGET_DATE = date(2026, 8, 28)
OBSERVED_AT = datetime(2026, 8, 28, 17, 30, tzinfo=UTC)
def result[T](api_name: str, rows: tuple[T, ...]) -> SourceResult[T]:
snapshot = build_source_snapshot(
api_name=api_name,
params={"trade_date": "20260828"},
rows=(),
target_trade_date=TARGET_DATE,
observed_at=OBSERVED_AT,
)
return SourceResult((snapshot,), rows)
def basic(ts_code: str, *, list_date: date = date(2020, 1, 1)) -> StockBasicRow:
return StockBasicRow(
ts_code=ts_code,
symbol=ts_code.split(".")[0],
name=ts_code,
market="主板",
exchange="SZSE",
list_status="L",
list_date=list_date,
delist_date=None,
)
def daily(ts_code: str, amount: Decimal | None) -> DailyRow:
return DailyRow(
ts_code=ts_code,
trade_date=TARGET_DATE,
close=Decimal("10"),
pre_close=Decimal("10"),
pct_chg=Decimal(0),
volume=Decimal(0),
amount_thousand_yuan=amount,
)
def moneyflow(ts_code: str, amount: Decimal | None) -> MoneyflowDcRow:
return MoneyflowDcRow(
trade_date=TARGET_DATE,
ts_code=ts_code,
name=ts_code,
net_amount_ten_thousand_yuan=amount,
net_amount_rate=Decimal(0),
pct_change=Decimal(0),
close=Decimal("10"),
)
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(
basic(code, list_date=date(2027, 1, 1) if code == codes[7] else date(2020, 1, 1))
for code in codes
)
daily_rows = (
daily(codes[0], Decimal("1")),
daily(codes[3], None),
daily(codes[4], Decimal("1")),
daily(codes[5], Decimal("1")),
daily(codes[6], Decimal("0")),
daily(codes[7], Decimal("1")),
)
moneyflow_rows = (
moneyflow(codes[0], Decimal("0")),
moneyflow(codes[3], Decimal("1")),
moneyflow(codes[5], None),
moneyflow(codes[6], Decimal("0")),
moneyflow(codes[7], Decimal("1")),
)
suspensions = (
SuspendRow(
ts_code=codes[1],
trade_date=TARGET_DATE,
suspend_timing="09:30",
suspend_type="停牌",
),
)
facts = normalize_stock_facts(
target_trade_date=TARGET_DATE,
candidate_codes=codes,
stock_basics=result("stock_basic", basics),
suspensions=result("suspend_d", suspensions),
daily=result("daily", daily_rows),
moneyflow=result("moneyflow_dc", moneyflow_rows),
)
by_code = {fact.ts_code: fact for fact in facts}
assert by_code[codes[0]].status is StockFactStatus.AVAILABLE
assert by_code[codes[0]].turnover_yuan == Decimal("1000")
assert by_code[codes[0]].net_amount_yuan == Decimal("0")
assert by_code[codes[1]].status is StockFactStatus.SUSPENDED
assert by_code[codes[2]].status is StockFactStatus.MISSING_DAILY
assert by_code[codes[3]].status is StockFactStatus.NULL_DAILY_AMOUNT
assert by_code[codes[4]].status is StockFactStatus.MISSING_MONEYFLOW
assert by_code[codes[5]].status is StockFactStatus.NULL_MONEYFLOW
assert by_code[codes[6]].status is StockFactStatus.LOW_LIQUIDITY
assert by_code[codes[7]].status is StockFactStatus.LIFECYCLE_INVALID
@@ -0,0 +1,170 @@
from collections.abc import Generator
from contextlib import contextmanager
from datetime import UTC, date, datetime
from decimal import Decimal
from typing import Any, cast
from psycopg_pool import ConnectionPool
from zhixing_server.modules.sector_radar.domain.models import PublicationStatus
from zhixing_server.modules.sector_radar.infrastructure.postgres import (
PostgresSectorRadarRepository,
)
TARGET_DATE = date(2026, 8, 28)
class FakeResult:
def __init__(
self,
row: tuple[object, ...] | None = None,
rows: tuple[tuple[object, ...], ...] | None = None,
) -> None:
self.row = row
self.rows = rows or (() if row is None else (row,))
def fetchone(self) -> tuple[object, ...] | None:
return self.row
def fetchall(self) -> tuple[tuple[object, ...], ...]:
return self.rows
class FakeConnection:
def __init__(self) -> None:
self.statements: list[tuple[str, tuple[object, ...]]] = []
def execute(
self,
query: str,
parameters: tuple[object, ...] = (),
) -> FakeResult:
self.statements.append((query, parameters))
if "FROM sector_radar_ranking" in query:
return FakeResult(
rows=(
(
TARGET_DATE,
"concept",
"BK0001.DC",
"机器人",
"amount",
"zhixing_amount_net_bn_v1",
"independent",
"CNY_100M",
Decimal("12.5"),
"available",
20,
19,
Decimal(1),
Decimal("0.95"),
1,
Decimal(100),
{"1": 3, "2": None},
),
)
)
if "FROM sector_radar_publication" in query:
return FakeResult(
(
"publication-a",
TARGET_DATE,
"success",
"tushare-pro-v1",
"eastmoney-dc-v1",
["zhixing_amount_net_bn_v1"],
"a" * 64,
Decimal("1"),
datetime(2026, 8, 28, 17, 30, tzinfo=UTC),
datetime(2026, 8, 28, 17, 35, tzinfo=UTC),
None,
)
)
if "pg_try_advisory_lock" in query:
return FakeResult((True,))
return FakeResult((True,))
class FakePool:
def __init__(self, connection: FakeConnection) -> None:
self._connection = connection
self._opened = False
def open(self, *, wait: bool) -> None:
assert wait
self._opened = True
def close(self) -> None:
self._opened = False
@contextmanager
def connection(self) -> Generator[FakeConnection]:
yield self._connection
def make_repository(connection: FakeConnection) -> PostgresSectorRadarRepository:
pool = cast(ConnectionPool[Any], cast(object, FakePool(connection)))
return PostgresSectorRadarRepository("postgresql://unused", pool=pool)
def test_last_good_query_strictly_filters_success_and_date() -> None:
connection = FakeConnection()
publication = make_repository(connection).get_last_good_publication(TARGET_DATE)
assert publication is not None
assert publication.status is PublicationStatus.SUCCESS
query, parameters = connection.statements[0]
assert "status = 'success'" in query
assert "partial" not in query
assert "target_trade_date <= %s" in query
assert parameters == (TARGET_DATE,)
def test_advisory_lock_uses_target_date_and_releases_same_key() -> None:
connection = FakeConnection()
with make_repository(connection).advisory_lock(TARGET_DATE) as acquired:
assert acquired
assert len(connection.statements) == 2
assert "pg_try_advisory_lock" in connection.statements[0][0]
assert "2026-08-28" in str(connection.statements[0][1][0])
assert "pg_advisory_unlock" in connection.statements[1][0]
assert connection.statements[0][1] == connection.statements[1][1]
def test_exact_success_and_latest_attempt_queries_use_distinct_semantics() -> None:
connection = FakeConnection()
repository = make_repository(connection)
exact = repository.get_successful_publication(TARGET_DATE)
latest = repository.get_latest_publication()
assert exact is not None
assert latest is not None
exact_query, exact_parameters = connection.statements[0]
latest_query, latest_parameters = connection.statements[1]
assert "status = 'success' AND target_trade_date = %s" in exact_query
assert exact_parameters == (TARGET_DATE,)
assert "status = 'success'" not in latest_query
assert "started_at DESC" in latest_query
assert latest_parameters == ()
def test_load_rankings_reconstructs_values_and_rank_changes() -> None:
connection = FakeConnection()
rankings = make_repository(connection).load_rankings("publication-a")
assert len(rankings) == 1
ranking = rankings[0]
assert ranking.observation.metric_version == "zhixing_amount_net_bn_v1"
assert ranking.observation.value == Decimal("12.5")
assert ranking.rank_position == 1
assert ranking.rank_change(1) == 3
assert ranking.rank_change(2) is None
query, parameters = connection.statements[0]
assert "WHERE publication_id = %s" in query
assert "rank_position NULLS LAST" in query
assert parameters == ("publication-a",)
@@ -0,0 +1,147 @@
from datetime import date
from decimal import Decimal
from zhixing_server.modules.sector_radar.domain.models import (
MetricKind,
MetricObservation,
MetricQuality,
MetricUnit,
RankSide,
SectorType,
)
from zhixing_server.modules.sector_radar.domain.ranking import (
rank_metric_observations,
select_percentile_side,
select_rank_change_side,
with_rank_changes,
)
TARGET_DATE = date(2026, 8, 28)
def make_observation(
sector_code: str,
sector_type: SectorType,
value: str | None,
*,
trade_date: date = TARGET_DATE,
) -> MetricObservation:
metric_value = Decimal(value) if value is not None else None
return MetricObservation(
trade_date=trade_date,
sector_type=sector_type,
sector_code=sector_code,
sector_name=sector_code,
metric_kind=MetricKind.AMOUNT,
metric_version="zhixing_amount_net_bn_v1",
implementation_kind="independent",
unit=MetricUnit.CNY_100M,
value=metric_value,
quality=(
MetricQuality.AVAILABLE if metric_value is not None else MetricQuality.UNAVAILABLE
),
member_count=10,
valid_sample_count=10 if metric_value is not None else 0,
membership_coverage=Decimal("1"),
moneyflow_coverage=Decimal("1"),
)
def test_ranking_separates_types_and_uses_code_as_stable_tie_breaker() -> None:
observations = (
make_observation("BK2002.DC", SectorType.INDUSTRY, "20"),
make_observation("BK1002.DC", SectorType.CONCEPT, "30"),
make_observation("BK2001.DC", SectorType.INDUSTRY, "20"),
make_observation("BK1001.DC", SectorType.CONCEPT, "10"),
)
ranked = rank_metric_observations(tuple(reversed(observations)))
by_code = {row.observation.sector_code: row for row in ranked}
assert by_code["BK1002.DC"].rank_position == 1
assert by_code["BK1002.DC"].rank_percentile == Decimal("100")
assert by_code["BK1001.DC"].rank_position == 2
assert by_code["BK1001.DC"].rank_percentile == Decimal("50")
assert by_code["BK2001.DC"].rank_position == 1
assert by_code["BK2002.DC"].rank_position == 2
def test_ranking_handles_empty_and_single_element_pools() -> None:
assert rank_metric_observations(()) == ()
[single] = rank_metric_observations((make_observation("BK0001.DC", SectorType.CONCEPT, "0"),))
assert single.rank_position == 1
assert single.rank_percentile == Decimal("100")
def test_percentile_sides_use_confirmed_inclusive_thresholds() -> None:
ranked = rank_metric_observations(
make_observation(f"BK{position:04d}.DC", SectorType.CONCEPT, str(11 - position))
for position in range(1, 11)
)
top = select_percentile_side(ranked, RankSide.TOP)
bottom = select_percentile_side(ranked, RankSide.BOTTOM)
assert [row.observation.sector_code for row in top] == ["BK0001.DC", "BK0002.DC"]
assert [row.observation.sector_code for row in bottom] == ["BK0010.DC"]
def test_rank_change_is_past_rank_minus_current_and_preserves_missing_history() -> None:
current = rank_metric_observations(
(
make_observation("BK0001.DC", SectorType.CONCEPT, "30"),
make_observation("BK0002.DC", SectorType.CONCEPT, "20"),
)
)
previous = rank_metric_observations(
(
make_observation(
"BK0001.DC",
SectorType.CONCEPT,
"10",
trade_date=date(2026, 8, 27),
),
make_observation(
"BK0002.DC",
SectorType.CONCEPT,
"40",
trade_date=date(2026, 8, 27),
),
)
)
changed = with_rank_changes(current, {1: previous, 5: ()})
by_code = {row.observation.sector_code: row for row in changed}
assert by_code["BK0001.DC"].rank_change(1) == 1
assert by_code["BK0002.DC"].rank_change(1) == -1
assert by_code["BK0001.DC"].rank_change(5) is None
def test_rank_change_sides_take_ceiling_ten_percent_per_pool() -> None:
current = rank_metric_observations(
make_observation(f"BK{position:04d}.DC", SectorType.CONCEPT, str(12 - position))
for position in range(1, 12)
)
previous = rank_metric_observations(
make_observation(
f"BK{position:04d}.DC",
SectorType.CONCEPT,
str(position),
trade_date=date(2026, 8, 27),
)
for position in range(1, 12)
)
changed = with_rank_changes(current, {1: previous})
top = select_rank_change_side(changed, days=1, side=RankSide.TOP)
bottom = select_rank_change_side(changed, days=1, side=RankSide.BOTTOM)
assert [row.observation.sector_code for row in top] == ["BK0001.DC", "BK0002.DC"]
assert [row.observation.sector_code for row in bottom] == [
"BK0011.DC",
"BK0010.DC",
]
@@ -0,0 +1,189 @@
from dataclasses import replace
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
from zhixing_server.modules.sector_radar.application.read import (
RadarQuery,
RadarView,
ReadSectorRadar,
)
from zhixing_server.modules.sector_radar.domain.metrics import AmountNetStrategy
from zhixing_server.modules.sector_radar.domain.models import (
MetricKind,
MetricObservation,
MetricQuality,
MetricUnit,
PublicationStatus,
RadarPublication,
RankChange,
RankedMetric,
RankSide,
SectorType,
)
from zhixing_server.modules.sector_radar.domain.persistence import RankingRecord
from zhixing_server.modules.sector_radar.domain.ranking import rank_metric_observations
from zhixing_server.modules.sector_radar.infrastructure.memory import (
InMemorySectorRadarRepository,
)
TARGET_DATE = date(2026, 8, 28)
NOW = datetime(2026, 8, 28, 17, 30, tzinfo=UTC)
def _running(publication_id: str, trade_date: date) -> RadarPublication:
return RadarPublication(
publication_id=publication_id,
target_trade_date=trade_date,
status=PublicationStatus.RUNNING,
source_version="tushare-pro-v1",
universe_version="eastmoney-dc-v1",
metric_versions=(AmountNetStrategy.metric_version,),
input_hash=None,
coverage=Decimal(0),
started_at=NOW,
)
def _finish(
publication: RadarPublication,
status: PublicationStatus,
) -> RadarPublication:
return replace(
publication,
status=status,
input_hash="a" * 64 if status is PublicationStatus.SUCCESS else None,
coverage=Decimal(1) if status is PublicationStatus.SUCCESS else Decimal("0.8"),
finished_at=publication.started_at + timedelta(minutes=5),
error_summary=None if status is PublicationStatus.SUCCESS else "safe_error",
)
def _amount_rankings() -> tuple[RankedMetric, ...]:
observations = tuple(
MetricObservation(
trade_date=TARGET_DATE,
sector_type=SectorType.CONCEPT,
sector_code=f"BK{index:04d}.DC",
sector_name=f"概念{index}",
metric_kind=MetricKind.AMOUNT,
metric_version=AmountNetStrategy.metric_version,
implementation_kind="independent",
unit=MetricUnit.CNY_100M,
value=Decimal(11 - index),
quality=MetricQuality.AVAILABLE,
member_count=5,
valid_sample_count=5,
membership_coverage=Decimal(1),
moneyflow_coverage=Decimal(1),
)
for index in range(1, 11)
)
rankings = rank_metric_observations(observations)
return tuple(
replace(
row,
rank_changes=tuple(
RankChange(
days=days,
value=(
None
if row.observation.sector_code == "BK0005.DC" and days == 5
else (row.rank_position or 0) - 5
),
)
for days in range(1, 6)
),
)
for row in rankings
)
def _published_repository() -> InMemorySectorRadarRepository:
repository = InMemorySectorRadarRepository()
publication = _running("publication-success", TARGET_DATE)
repository.create_publication(publication)
repository.finish_publication(_finish(publication, PublicationStatus.SUCCESS))
repository.save_rankings(
RankingRecord(publication.publication_id, ranking) for ranking in _amount_rankings()
)
return repository
def test_no_successful_publication_returns_stable_no_data() -> None:
reader = ReadSectorRadar(InMemorySectorRadarRepository())
dates = reader.list_dates()
rankings = reader.query(RadarQuery())
assert dates.status == "no_data"
assert dates.available_dates == ()
assert rankings.status == "no_data"
assert rankings.publication is None
assert rankings.total == 0
assert rankings.definition.metric_version == AmountNetStrategy.metric_version
def test_explicit_date_never_falls_back_to_an_earlier_last_good() -> None:
reader = ReadSectorRadar(_published_repository())
missing = reader.query(RadarQuery(trade_date=TARGET_DATE + timedelta(days=1)))
assert missing.status == "no_data"
assert missing.publication is None
def test_percentile_side_is_selected_before_search_and_pagination() -> None:
reader = ReadSectorRadar(_published_repository())
top = reader.query(RadarQuery(side=RankSide.TOP, page_size=1))
second_page = reader.query(RadarQuery(side=RankSide.TOP, page=2, page_size=1))
searched = reader.query(RadarQuery(side=RankSide.TOP, search="概念2"))
bottom = reader.query(RadarQuery(side=RankSide.BOTTOM))
assert top.total == 2
assert top.rows[0].observation.sector_code == "BK0001.DC"
assert second_page.rows[0].observation.sector_code == "BK0002.DC"
assert searched.total == 1
assert searched.rows[0].observation.sector_name == "概念2"
assert bottom.total == 1
assert bottom.rows[0].observation.sector_code == "BK0010.DC"
def test_rank_change_uses_selected_metric_days_and_pool_sides() -> None:
reader = ReadSectorRadar(_published_repository())
query = RadarQuery(
view=RadarView.RANK_CHANGE,
rank_change_metric=MetricKind.AMOUNT,
rank_change_days=5,
)
top = reader.query(replace(query, side=RankSide.TOP))
bottom = reader.query(replace(query, side=RankSide.BOTTOM))
all_rows = reader.query(query)
assert top.total == 1
assert top.rows[0].rank_change(5) == 5
assert bottom.total == 1
assert bottom.rows[0].rank_change(5) == -4
assert all_rows.total == 10
assert all_rows.rows[-1].observation.sector_code == "BK0005.DC"
assert all_rows.rows[-1].rank_change(5) is None
def test_latest_partial_attempt_is_visible_but_does_not_replace_last_good() -> None:
repository = _published_repository()
partial = replace(
_running("publication-partial", TARGET_DATE + timedelta(days=1)),
started_at=NOW + timedelta(days=1),
)
repository.create_publication(partial)
repository.finish_publication(_finish(partial, PublicationStatus.PARTIAL))
index = ReadSectorRadar(repository).list_dates()
assert index.status == "success"
assert index.current_attempt is not None
assert index.current_attempt.status is PublicationStatus.PARTIAL
assert index.last_good is not None
assert index.last_good.publication_id == "publication-success"
assert index.available_dates == (TARGET_DATE,)
@@ -0,0 +1,121 @@
from dataclasses import replace
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
import pytest
from zhixing_server.modules.sector_radar.domain.models import (
PublicationStatus,
RadarPublication,
SectorType,
)
from zhixing_server.modules.sector_radar.domain.persistence import MembershipRecord
from zhixing_server.modules.sector_radar.domain.source import build_source_snapshot
from zhixing_server.modules.sector_radar.infrastructure.memory import (
InMemorySectorRadarRepository,
)
TARGET_DATE = date(2026, 8, 28)
STARTED_AT = datetime(2026, 8, 28, 17, 30, tzinfo=UTC)
def make_running(publication_id: str, target_trade_date: date = TARGET_DATE) -> RadarPublication:
return RadarPublication(
publication_id=publication_id,
target_trade_date=target_trade_date,
status=PublicationStatus.RUNNING,
source_version="tushare-pro-v1",
universe_version="eastmoney-dc-v1",
metric_versions=("zhixing_amount_net_bn_v1",),
input_hash=None,
coverage=Decimal(0),
started_at=STARTED_AT,
)
def finish(
publication: RadarPublication,
status: PublicationStatus,
*,
offset_minutes: int = 5,
) -> RadarPublication:
return replace(
publication,
status=status,
input_hash="a" * 64 if status is PublicationStatus.SUCCESS else None,
coverage=Decimal("1") if status is PublicationStatus.SUCCESS else Decimal("0.8"),
finished_at=publication.started_at + timedelta(minutes=offset_minutes),
error_summary=None if status is PublicationStatus.SUCCESS else "safe_error",
)
def test_source_and_membership_revisions_are_idempotent_but_not_overwritable() -> None:
repository = InMemorySectorRadarRepository()
snapshot = build_source_snapshot(
api_name="dc_member",
params={"trade_date": "20260828"},
rows=(
{
"trade_date": "20260828",
"ts_code": "BK0001.DC",
"con_code": "000001.SZ",
"name": "平安银行",
},
),
target_trade_date=TARGET_DATE,
observed_at=STARTED_AT,
)
member = MembershipRecord(
source_snapshot_id=snapshot.snapshot_id,
trade_date=TARGET_DATE,
sector_type=SectorType.CONCEPT,
sector_code="BK0001.DC",
sector_name="示例概念",
stock_code="000001.SZ",
stock_name="平安银行",
)
assert repository.save_source_snapshots((snapshot,)).inserted == 1
assert repository.save_source_snapshots((snapshot,)).unchanged == 1
assert repository.save_memberships((member,)).inserted == 1
assert repository.save_memberships((member,)).unchanged == 1
with pytest.raises(ValueError, match="cannot change content"):
repository.save_memberships((replace(member, stock_name="已改变"),))
def test_partial_and_failed_revisions_never_replace_last_good() -> None:
repository = InMemorySectorRadarRepository()
successful = make_running("success-a")
partial = make_running("partial-b")
failed = make_running("failed-c", TARGET_DATE + timedelta(days=1))
repository.create_publication(successful)
repository.finish_publication(finish(successful, PublicationStatus.SUCCESS))
repository.create_publication(partial)
repository.finish_publication(finish(partial, PublicationStatus.PARTIAL, offset_minutes=6))
repository.create_publication(failed)
repository.finish_publication(finish(failed, PublicationStatus.FAILED, offset_minutes=7))
last_good = repository.get_last_good_publication()
assert last_good is not None
assert last_good.publication_id == "success-a"
assert repository.list_successful_dates() == (TARGET_DATE,)
def test_publication_identity_allows_sequential_same_date_revisions() -> None:
repository = InMemorySectorRadarRepository()
first = make_running("revision-a")
second = make_running("revision-b")
assert repository.create_publication(first).inserted == 1
with pytest.raises(ValueError, match="already has a running"):
repository.create_publication(second)
with pytest.raises(ValueError, match="terminal"):
repository.finish_publication(first)
repository.finish_publication(finish(first, PublicationStatus.FAILED))
assert repository.create_publication(second).inserted == 1
with pytest.raises(ValueError, match="running status"):
repository.finish_publication(finish(first, PublicationStatus.SUCCESS))
@@ -0,0 +1,343 @@
from collections.abc import Mapping
from datetime import UTC, date, datetime
from decimal import Decimal
import pytest
from zhixing_server.modules.sector_radar.domain.models import SectorType
from zhixing_server.modules.sector_radar.domain.source import (
CapabilityStatus,
SourceContractError,
build_source_snapshot,
)
from zhixing_server.modules.sector_radar.infrastructure import tushare as source_module
from zhixing_server.modules.sector_radar.infrastructure.tushare import (
TushareSectorRadarAdapter,
)
TARGET_DATE = date(2026, 8, 28)
OBSERVED_AT = datetime(2026, 8, 28, 17, 30, tzinfo=UTC)
class QueryClient:
def __init__(self, responses: Mapping[tuple[str, str], object]) -> None:
self.responses = dict(responses)
self.calls: list[tuple[str, dict[str, object]]] = []
def query(self, api_name: str, **kwargs: object) -> object:
self.calls.append((api_name, kwargs))
partition = str(kwargs.get("ts_code") or kwargs.get("list_status") or "")
response = self.responses.get((api_name, partition), ())
if isinstance(response, BaseException):
raise response
return response
def make_adapter(client: object) -> TushareSectorRadarAdapter:
return TushareSectorRadarAdapter(
client,
max_retries=0,
request_interval_seconds=0,
sleep_fn=lambda _: None,
now_fn=lambda: OBSERVED_AT,
)
def test_daily_and_moneyflow_keep_source_units_and_distinguish_missing_from_zero() -> None:
client = QueryClient(
{
(
"daily",
"",
): (
{
"ts_code": "000001.SZ",
"trade_date": "20260828",
"close": "10",
"pre_close": "9.5",
"pct_chg": "1.5",
"vol": "100",
"amount": "12.5",
},
{
"ts_code": "000002.SZ",
"trade_date": "20260828",
"close": "20",
"pre_close": "20",
"pct_chg": "0",
"vol": "0",
"amount": float("nan"),
},
),
(
"moneyflow_dc",
"",
): (
{
"trade_date": "20260828",
"ts_code": "000001.SZ",
"name": "平安银行",
"net_amount": "2.5",
"net_amount_rate": "0.2",
"pct_change": "1.5",
"close": "10",
},
{
"trade_date": "20260828",
"ts_code": "000002.SZ",
"name": "示例股票",
"net_amount": "0",
"net_amount_rate": "0",
"pct_change": "0",
"close": "20",
},
),
}
)
adapter = make_adapter(client)
daily = adapter.fetch_daily(TARGET_DATE)
moneyflow = adapter.fetch_moneyflow_dc(TARGET_DATE)
assert daily.rows[0].amount_thousand_yuan == Decimal("12.5")
assert daily.rows[0].turnover_yuan == Decimal("12500.0")
assert daily.rows[1].amount_thousand_yuan is None
assert moneyflow.rows[0].net_amount_ten_thousand_yuan == Decimal("2.5")
assert moneyflow.rows[0].net_amount_yuan == Decimal("25000.0")
assert moneyflow.rows[1].net_amount_yuan == Decimal("0")
assert client.calls[0][1]["fields"] == ",".join(source_module.FIELDS["daily"])
def test_non_finite_source_values_are_rejected() -> None:
client = QueryClient(
{
(
"daily",
"",
): (
{
"ts_code": "000001.SZ",
"trade_date": "20260828",
"close": "Infinity",
"pre_close": "9.5",
"pct_chg": "1.5",
"vol": "100",
"amount": "12.5",
},
)
}
)
with pytest.raises(SourceContractError, match="finite"):
make_adapter(client).fetch_daily(TARGET_DATE)
def test_dc_member_reloads_by_sector_when_the_all_market_call_hits_limit(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setitem(source_module.ROW_LIMITS, "dc_member", 2)
client = QueryClient(
{
(
"dc_member",
"",
): (
{
"trade_date": "20260828",
"ts_code": "BK0001.DC",
"con_code": "000001.SZ",
"name": "A",
},
{
"trade_date": "20260828",
"ts_code": "BK0001.DC",
"con_code": "000002.SZ",
"name": "B",
},
),
(
"dc_member",
"BK0001.DC",
): (
{
"trade_date": "20260828",
"ts_code": "BK0001.DC",
"con_code": "000001.SZ",
"name": "A",
},
),
(
"dc_member",
"BK0002.DC",
): (
{
"trade_date": "20260828",
"ts_code": "BK0002.DC",
"con_code": "600000.SH",
"name": "C",
},
),
}
)
result = make_adapter(client).fetch_sector_members(
TARGET_DATE,
("BK0001.DC", "BK0002.DC"),
)
assert [row.stock_code for row in result.rows] == ["000001.SZ", "600000.SH"]
assert [snapshot.partition_key for snapshot in result.snapshots] == [
"all",
"BK0001.DC",
"BK0002.DC",
]
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 = {
(
"stock_basic",
status,
): (
{
"ts_code": f"00000{index}.SZ",
"symbol": f"00000{index}",
"name": status,
"market": "主板",
"exchange": "SZSE",
"list_status": status,
"list_date": "20200101",
"delist_date": None,
},
)
for index, status in enumerate(("L", "D", "P", "G", "UN"), start=1)
}
client = QueryClient(responses)
result = make_adapter(client).fetch_stock_basics()
assert {row.list_status for row in result.rows} == {"L", "D", "P", "G", "UN"}
assert [call[1]["list_status"] for call in client.calls] == ["L", "D", "P", "G", "UN"]
def test_source_snapshot_hash_is_order_stable_and_excludes_token_params() -> None:
first = build_source_snapshot(
api_name="daily",
params={"trade_date": "20260828", "token": "secret"},
rows=({"ts_code": "2"}, {"ts_code": "1"}),
target_trade_date=TARGET_DATE,
observed_at=OBSERVED_AT,
)
second = build_source_snapshot(
api_name="daily",
params={"trade_date": "20260828"},
rows=({"ts_code": "1"}, {"ts_code": "2"}),
target_trade_date=TARGET_DATE,
observed_at=OBSERVED_AT,
)
assert first.snapshot_id == second.snapshot_id
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")})
probe = make_adapter(client).probe(TARGET_DATE)
by_name = {result.api_name: result for result in probe.interfaces}
assert by_name["daily"].status is CapabilityStatus.FORBIDDEN
assert "private-detail" not in repr(probe)
assert len(probe.interfaces) == 7
def test_sector_index_uses_independent_concept_and_industry_params() -> None:
client = QueryClient(
{
(
"dc_index",
"",
): (
{
"ts_code": "BK0001.DC",
"trade_date": "20260828",
"name": "示例",
"idx_type": "概念板块",
"level": "一级",
"pct_change": "1",
"leading_code": "000001.SZ",
},
)
}
)
result = make_adapter(client).fetch_sector_indices(TARGET_DATE, SectorType.CONCEPT)
assert result.rows[0].sector_type is SectorType.CONCEPT
assert client.calls[0][1]["idx_type"] == "概念板块"
+26 -4
View File
@@ -263,6 +263,7 @@ The single chromatic accent is **Fin Orange** (`{colors.fin-orange}` #ff5600)
The page rhythm is heavy on **product mockups**: every section's payload is a high-fidelity screenshot of Intercom's product UI, framed in white cards with a consistent 6px corner radius. The marketing chrome is intentionally quiet so the product can be the protagonist.
**Key Characteristics:**
- **Cream canvas** (`{colors.canvas}` #f5f1ec) is the brand's defining surface — neither white nor gray, deliberately warm.
- Product-screenshot-led page rhythm: every section centers a product mockup card, marketing chrome stays minimal.
- **Saans** proprietary sans-serif carries the entire hierarchy; SaansMono for code-only contexts.
@@ -276,6 +277,7 @@ The page rhythm is heavy on **product mockups**: every section's payload is a hi
> Source pages: intercom.com (home), /pricing, /helpdesk, /customers, /helpdesk/inbox.
### Brand & Accent
- **Charcoal** ({colors.ink}): The system primary surface. Headlines, body type, and primary CTA background — all charcoal.
- **White** ({colors.on-primary}): Text on charcoal CTAs; canvas of floating cards.
- **Fin Orange** ({colors.fin-orange}): The AI-product accent. Used on the Fin CTA, Fin badge, and a small set of inline emphasis moments.
@@ -283,6 +285,7 @@ The page rhythm is heavy on **product mockups**: every section's payload is a hi
- **Brand Blue** ({colors.brand-blue}): Saturated brand blue (#0007cb) — used on a small set of marketing illustrations.
### Surface
- **Canvas** ({colors.canvas}): Default page background — soft cream-white #f5f1ec.
- **Surface 1** ({colors.surface-1}): Pure white — used for floating cards (pricing, feature, product-mockup).
- **Surface 2** ({colors.surface-2}): Slightly darker cream — startup-discount banner, alt-row stripes.
@@ -292,6 +295,7 @@ The page rhythm is heavy on **product mockups**: every section's payload is a hi
- **Inverse Surface 1** ({colors.inverse-surface-1}): One step lighter — hovered footer items in dark contexts.
### Text
- **Ink** ({colors.ink}): All headlines, body type, button labels — charcoal #111111.
- **Ink Muted** ({colors.ink-muted}): Secondary type at #626260 — meta info, deselected pricing tabs.
- **Ink Subtle** ({colors.ink-subtle}): Tertiary type at #7b7b78 — footer columns, helper text.
@@ -300,6 +304,7 @@ The page rhythm is heavy on **product mockups**: every section's payload is a hi
- **Inverse Ink Muted** ({colors.inverse-ink-muted}): Light gray on black — quote-strip meta.
### Semantic & Report Palette (in-product mockups)
- **Error Red** ({colors.semantic-error}): Form validation, destructive states.
- **Success Green** ({colors.semantic-success}): Positive states (also `{colors.report-green}`).
- **Report Blue** ({colors.report-blue}): Analytics chart blue.
@@ -321,7 +326,7 @@ The same family carries the entire hierarchy. Hierarchy is carried by size + wei
### Hierarchy
| Token | Size | Weight | Line Height | Letter Spacing | Use |
|---|---|---|---|---|---|
| ------------------------- | ---- | ------ | ----------- | -------------- | ------------------------------- |
| `{typography.display-xl}` | 72px | 500 | 1.05 | -2.0px | Largest hero headline |
| `{typography.display-lg}` | 56px | 500 | 1.10 | -1.4px | Section opener headlines |
| `{typography.display-md}` | 40px | 500 | 1.15 | -0.8px | Sub-section headlines |
@@ -371,7 +376,7 @@ The cream canvas does the work white space would in another brand. Sections sepa
## Elevation & Depth
| Level | Treatment | Use |
|---|---|---|
| ----------------- | ---------------------------------------------------------------- | --------------------------------------------- |
| 0 (flat) | No shadow, no border | Default for body type, hero text, footer |
| 1 (lift on cream) | `{colors.surface-1}` white background on `{colors.canvas}` cream | Pricing cards, feature cards, product mockups |
| 2 (hairline lift) | `{colors.surface-1}` + 1px `{colors.hairline}` border | Floating tiles with extra definition |
@@ -389,7 +394,7 @@ Intercom resists drop shadows. Depth is communicated by the white-on-cream surfa
### Border Radius Scale
| Token | Value | Use |
|---|---|---|
| ---------------- | ----- | -------------------------------------- |
| `{rounded.xs}` | 6px | Small chips, badges |
| `{rounded.sm}` | 6px | Inline tags |
| `{rounded.md}` | 6px | All buttons, form inputs |
@@ -410,68 +415,85 @@ Intercom resists drop shadows. Depth is communicated by the white-on-cream surfa
### Buttons
**`button-primary`** — Charcoal CTA. The default primary CTA across all pages.
- Background `{colors.ink}`, text `{colors.on-primary}`, type `{typography.button}`, padding 10px 18px, rounded `{rounded.md}`.
- Pressed state lives in `button-primary-pressed`.
**`button-secondary`** — White button on cream. Used for secondary CTAs.
- Background `{colors.surface-1}`, text `{colors.ink}`, type `{typography.button}`, padding 10px 18px, rounded `{rounded.md}`. 1px `{colors.hairline}` border.
**`button-tertiary`** — Plain text button.
- Background `{colors.canvas}`, text `{colors.ink}`, type `{typography.button}`, rounded `{rounded.md}`, padding 10px 18px.
**`button-fin`** — Fin Orange CTA — reserved for Fin AI product CTAs.
- Background `{colors.fin-orange}`, text `{colors.on-primary}`, type `{typography.button}`, rounded `{rounded.md}`, padding 10px 18px.
### Pricing Tabs
**`pricing-tab-default`** + **`pricing-tab-selected`** — Compact tab toggle on `/pricing`.
- Default: `{colors.canvas}` background, `{colors.ink-muted}` text, rounded `{rounded.md}` (6px).
- Selected: `{colors.surface-1}` white background, `{colors.ink}` text — selected = lift onto white.
### Cards & Containers
**`pricing-card`** — Each tier on `/pricing`.
- Background `{colors.surface-1}`, text `{colors.ink}`, type `{typography.body}`, rounded `{rounded.lg}`, padding 24px.
**`pricing-card-featured`** — Featured / recommended tier — inverts to charcoal.
- Background `{colors.ink}`, text `{colors.on-primary}`, otherwise identical structure.
**`feature-card`** — Generic feature highlight.
- Background `{colors.surface-1}`, text `{colors.ink}`, type `{typography.body}`, rounded `{rounded.lg}`, padding 24px.
**`product-mockup-card`** — The dominant card type — frames a high-fidelity product UI screenshot.
- Background `{colors.surface-1}`, text `{colors.ink}`, type `{typography.body}`, rounded `{rounded.xl}`, padding 24px.
**`testimonial-card`** — Customer quote with avatar + name + company.
- Background `{colors.surface-1}`, text `{colors.ink}`, type `{typography.body-lg}`, rounded `{rounded.lg}`, padding 32px.
**`startup-discount-card`** — The "Startups get 90% off" tinted card.
- Background `{colors.surface-2}`, text `{colors.ink}`, type `{typography.body}`, rounded `{rounded.lg}`, padding 32px.
**`customer-logo-tile`** — Small tile in the customer marquee.
- Background `{colors.canvas}`, text `{colors.ink-muted}`, type `{typography.caption}`, rounded `{rounded.xs}`, padding 16px.
**`cta-banner`** — Closing CTA panel near page bottom.
- Background `{colors.surface-1}`, text `{colors.ink}`, type `{typography.headline}`, rounded `{rounded.lg}`, padding 48px.
### Inputs & Forms
**`text-input`** + **`text-input-focused`** — Form fields on contact and search overlays.
- Background `{colors.surface-1}`, text `{colors.ink}`, type `{typography.body}`, rounded `{rounded.md}`, padding 10px 14px.
### FAQ
**`faq-row`** — Expandable accordion row in the pricing FAQ.
- Background `{colors.canvas}`, text `{colors.ink}`, type `{typography.body}`, rounded `{rounded.md}`, padding 24px. 1px `{colors.hairline-soft}` bottom rule.
### Navigation
**`top-nav`** — Sticky cream bar with the Intercom wordmark left, nav links centered, log-in + sign-up pair right.
- Background `{colors.canvas}`, text `{colors.ink}`, type `{typography.body-sm}`, height 56px.
### Footer
**`footer`** — Dense link grid on `{colors.canvas}` cream with the Intercom wordmark left.
- Background `{colors.canvas}`, text `{colors.ink-muted}`, type `{typography.caption}`, padding 64px 32px.
## Do's and Don'ts
@@ -502,7 +524,7 @@ Intercom resists drop shadows. Depth is communicated by the white-on-cream surfa
### Breakpoints
| Name | Width | Key Changes |
|---|---|---|
| ---------- | ------ | --------------------------------------------------- |
| Desktop-XL | 1440px | Default desktop layout |
| Desktop | 1280px | Card grid 3-up maintained |
| Tablet | 1024px | Card grid 3-up → 2-up |
@@ -23,6 +23,7 @@ import {
function useActiveRoutePath() {
const matchRoute = useMatchRoute()
if (matchRoute({ to: "/sector-radar", fuzzy: true })) return "/sector-radar"
if (matchRoute({ to: "/selection", fuzzy: true })) return "/selection"
if (matchRoute({ to: "/sync", fuzzy: true })) return "/sync"
if (matchRoute({ to: "/components", fuzzy: true })) return "/components"
@@ -26,3 +26,24 @@ describe("sync navigation", () => {
})
})
})
describe("sector radar navigation", () => {
it("exposes the radar route in desktop and mobile navigation", () => {
const radar = primaryNavigation.find((item) => item.id === "sector-radar")
expect(radar).toMatchObject({
availability: "available",
label: "资金雷达",
to: "/sector-radar",
})
expect(mobilePrimaryNavigation).toContain(radar)
})
it("provides the active route presentation for the radar", () => {
expect(routePresentation["/sector-radar"]).toEqual({
breadcrumb: "研究工作台",
id: "sector-radar",
title: "板块资金雷达",
})
})
})
+11 -6
View File
@@ -1,9 +1,9 @@
import {
BarChart3,
BrainCircuit,
ClipboardCheck,
Component,
LayoutDashboard,
Radar,
type LucideIcon,
} from "lucide-react"
@@ -26,11 +26,11 @@ export const primaryNavigation: readonly NavigationItem[] = [
availability: "available",
},
{
id: "market",
label: "行情数据",
to: null,
icon: BarChart3,
availability: "unavailable",
id: "sector-radar",
label: "资金雷达",
to: "/sector-radar",
icon: Radar,
availability: "available",
},
{
id: "sync",
@@ -79,6 +79,11 @@ export const routePresentation: Record<string, RoutePresentation> = {
breadcrumb: "研究工作台",
title: "知行 B1 执行结果",
},
"/sector-radar": {
id: "sector-radar",
breadcrumb: "研究工作台",
title: "板块资金雷达",
},
"/sync": {
id: "sync",
breadcrumb: "研究工作台",
@@ -0,0 +1,182 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
const requestJson = vi.hoisted(() => vi.fn())
vi.mock("@/shared/api/request-json", () => ({ requestJson }))
import { getSectorRadarDates, getSectorRadarRankings } from "./sector-radar.api"
const publication = {
publication_id: "publication-1",
target_trade_date: "2026-08-28",
status: "success",
source_version: "tushare-pro-v1",
universe_version: "eastmoney-dc-v1",
metric_versions: ["zhixing_amount_net_bn_v1"],
input_hash: "a".repeat(64),
coverage: "0.99",
started_at: "2026-08-28T08:00:00Z",
finished_at: "2026-08-28T08:05:00Z",
error_summary: null,
}
const rankingPayload = {
status: "success",
requested_trade_date: "2026-08-28",
sector_type: "concept",
view: "rank_change",
rank_change_metric: "amount",
rank_change_days: 5,
side: "top",
search: "机器人",
publication,
definition: {
metric_kind: "amount",
metric_version: "zhixing_amount_net_bn_v1",
label: "主力净流入(知行独立实现)",
unit: "CNY_100M",
implementation_kind: "independent",
disclaimer: "知行独立实现,非 OneChartLab 原站公式。",
},
page: 2,
page_size: 10,
total: 1,
rows: [
{
trade_date: "2026-08-28",
sector_type: "concept",
sector_code: "BK0001.DC",
sector_name: "机器人",
metric_kind: "amount",
metric_version: "zhixing_amount_net_bn_v1",
implementation_kind: "independent",
unit: "CNY_100M",
metric_value: "12.5",
quality: "available",
member_count: 20,
valid_sample_count: 19,
membership_coverage: "1",
moneyflow_coverage: "0.95",
rank_position: 1,
rank_percentile: "100",
rank_change_days: 5,
rank_change: 3,
},
],
}
describe("sector radar API adapters", () => {
beforeEach(() => {
requestJson.mockReset()
})
it("forwards AbortSignal and normalizes decimal publication fields", async () => {
const signal = new AbortController().signal
requestJson.mockResolvedValue({
status: "success",
available_dates: ["2026-08-28"],
current_attempt: publication,
last_good: publication,
})
const result = await getSectorRadarDates(signal)
expect(requestJson).toHaveBeenCalledWith("/api/v1/sector-radar/dates", {
signal,
})
expect(result.last_good?.coverage).toBe(0.99)
})
it("maps URL-backed filters to the ranking HTTP contract", async () => {
const signal = new AbortController().signal
requestJson.mockResolvedValue(rankingPayload)
const result = await getSectorRadarRankings(
{
tradeDate: "2026-08-28",
sectorType: "concept",
view: "rank_change",
rankChangeMetric: "amount",
rankChangeDays: 5,
side: "top",
search: " 机器人 ",
page: 2,
pageSize: 10,
},
signal,
)
const [input, init] = requestJson.mock.calls[0] as [
string,
{ signal?: AbortSignal },
]
const params = new URL(input, "http://localhost").searchParams
expect(input).toContain("/api/v1/sector-radar/rankings?")
expect(params.get("trade_date")).toBe("2026-08-28")
expect(params.get("sector_type")).toBe("concept")
expect(params.get("view")).toBe("rank_change")
expect(params.get("rank_change_metric")).toBe("amount")
expect(params.get("rank_change_days")).toBe("5")
expect(params.get("side")).toBe("top")
expect(params.get("search")).toBe("机器人")
expect(params.get("page")).toBe("2")
expect(params.get("page_size")).toBe("10")
expect(init).toEqual({ signal })
expect(result.rows[0]?.metric_value).toBe(12.5)
expect(result.rows[0]?.rank_percentile).toBe(100)
})
it("rejects unknown stable enum values at the feature boundary", async () => {
requestJson.mockResolvedValue({ ...rankingPayload, view: "private_score" })
await expect(
getSectorRadarRankings({
sectorType: "concept",
view: "amount",
rankChangeMetric: "amount",
rankChangeDays: 1,
side: "all",
page: 1,
pageSize: 20,
}),
).rejects.toThrow("Invalid sector radar response: rankings.view")
})
it("rejects non-finite decimal strings instead of rendering them", async () => {
requestJson.mockResolvedValue({
...rankingPayload,
rows: [{ ...rankingPayload.rows[0], metric_value: "NaN" }],
})
await expect(
getSectorRadarRankings({
sectorType: "concept",
view: "amount",
rankChangeMetric: "amount",
rankChangeDays: 1,
side: "all",
page: 1,
pageSize: 20,
}),
).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")
})
})
@@ -0,0 +1,420 @@
import { requestJson } from "@/shared/api/request-json"
import {
radarMetricKinds,
radarMetricQualities,
radarMetricUnits,
radarPublicationStatuses,
radarRankSides,
radarViews,
sectorTypes,
type RadarDatesResponse,
type RadarMetricDefinition,
type RadarPublication,
type RadarRankingRow,
type RadarRankingsQuery,
type RadarRankingsResponse,
} from "./sector-radar.types"
type JsonRecord = Record<string, unknown>
/**
* Fetch and validate the available sector-radar publication dates.
*
* @param signal - React Query cancellation signal forwarded to fetch.
* @returns A normalized response whose decimal fields are finite numbers.
* @throws Error when the server response violates the stable HTTP contract.
*/
export async function getSectorRadarDates(signal?: AbortSignal) {
const payload = await requestJson<unknown>("/api/v1/sector-radar/dates", {
signal,
})
return parseRadarDatesResponse(payload)
}
/**
* Fetch one persisted ranking page using the backend's snake-case query names.
*
* @param query - URL-backed ranking filters owned by the sector-radar feature.
* @param signal - React Query cancellation signal forwarded to fetch.
* @returns A validated and normalized ranking page.
* @throws Error when the server response violates the stable HTTP contract.
*/
export async function getSectorRadarRankings(
query: RadarRankingsQuery,
signal?: AbortSignal,
) {
const params = new URLSearchParams({
sector_type: query.sectorType,
view: query.view,
rank_change_metric: query.rankChangeMetric,
rank_change_days: String(query.rankChangeDays),
side: query.side,
page: String(query.page),
page_size: String(query.pageSize),
})
if (query.tradeDate) params.set("trade_date", query.tradeDate)
if (query.search?.trim()) params.set("search", query.search.trim())
const payload = await requestJson<unknown>(
`/api/v1/sector-radar/rankings?${params.toString()}`,
{ signal },
)
return parseRadarRankingsResponse(payload)
}
/**
* Validate the dates response at the feature boundary.
*
* The shared transport intentionally does not own feature schemas. Decimal
* strings emitted by Pydantic are converted here so the page never handles
* mixed string/number arithmetic.
*/
export function parseRadarDatesResponse(value: unknown): RadarDatesResponse {
const record = readRecord(value, "dates")
return {
status: readEnum(record.status, ["success", "no_data"], "dates.status"),
available_dates: readArray(
record.available_dates,
"dates.available_dates",
).map((item, index) => readDate(item, `dates.available_dates[${index}]`)),
current_attempt: readNullablePublication(
record.current_attempt,
"dates.current_attempt",
),
last_good: readNullablePublication(record.last_good, "dates.last_good"),
}
}
/** Validate and normalize one ranking response from the same-repository API. */
export function parseRadarRankingsResponse(
value: unknown,
): RadarRankingsResponse {
const record = readRecord(value, "rankings")
return {
status: readEnum(record.status, ["success", "no_data"], "rankings.status"),
requested_trade_date: readNullableDate(
record.requested_trade_date,
"rankings.requested_trade_date",
),
sector_type: readEnum(
record.sector_type,
sectorTypes,
"rankings.sector_type",
),
view: readEnum(record.view, radarViews, "rankings.view"),
rank_change_metric: readEnum(
record.rank_change_metric,
radarMetricKinds,
"rankings.rank_change_metric",
),
rank_change_days: readIntegerInRange(
record.rank_change_days,
1,
5,
"rankings.rank_change_days",
),
side: readEnum(record.side, radarRankSides, "rankings.side"),
search: readNullableString(record.search, "rankings.search"),
publication: readNullablePublication(
record.publication,
"rankings.publication",
),
definition: readMetricDefinition(record.definition),
page: readIntegerInRange(
record.page,
1,
Number.MAX_SAFE_INTEGER,
"rankings.page",
),
page_size: readIntegerInRange(
record.page_size,
1,
100,
"rankings.page_size",
),
total: readIntegerInRange(
record.total,
0,
Number.MAX_SAFE_INTEGER,
"rankings.total",
),
rows: readArray(record.rows, "rankings.rows").map((item, index) =>
readRankingRow(item, index),
),
}
}
function readNullablePublication(
value: unknown,
path: string,
): RadarPublication | null {
if (value === null) return null
const record = readRecord(value, path)
return {
publication_id: readNonEmptyString(
record.publication_id,
`${path}.publication_id`,
),
target_trade_date: readDate(
record.target_trade_date,
`${path}.target_trade_date`,
),
status: readEnum(record.status, radarPublicationStatuses, `${path}.status`),
source_version: readNonEmptyString(
record.source_version,
`${path}.source_version`,
),
universe_version: readNonEmptyString(
record.universe_version,
`${path}.universe_version`,
),
metric_versions: readArray(
record.metric_versions,
`${path}.metric_versions`,
).map((item, index) =>
readNonEmptyString(item, `${path}.metric_versions[${index}]`),
),
input_hash: readNullableString(record.input_hash, `${path}.input_hash`),
coverage: readFraction(record.coverage, `${path}.coverage`),
started_at: readDateTime(record.started_at, `${path}.started_at`),
finished_at: readNullableDateTime(
record.finished_at,
`${path}.finished_at`,
),
error_summary: readNullableString(
record.error_summary,
`${path}.error_summary`,
),
}
}
function readMetricDefinition(value: unknown): RadarMetricDefinition {
const path = "rankings.definition"
const record = readRecord(value, path)
return {
metric_kind: readEnum(
record.metric_kind,
radarMetricKinds,
`${path}.metric_kind`,
),
metric_version: readNonEmptyString(
record.metric_version,
`${path}.metric_version`,
),
label: readNonEmptyString(record.label, `${path}.label`),
unit: readEnum(record.unit, radarMetricUnits, `${path}.unit`),
implementation_kind: readEnum(
record.implementation_kind,
["independent"],
`${path}.implementation_kind`,
),
disclaimer: readNonEmptyString(record.disclaimer, `${path}.disclaimer`),
}
}
function readRankingRow(value: unknown, index: number): RadarRankingRow {
const path = `rankings.rows[${index}]`
const record = readRecord(value, path)
return {
trade_date: readDate(record.trade_date, `${path}.trade_date`),
sector_type: readEnum(
record.sector_type,
sectorTypes,
`${path}.sector_type`,
),
sector_code: readNonEmptyString(record.sector_code, `${path}.sector_code`),
sector_name: readNonEmptyString(record.sector_name, `${path}.sector_name`),
metric_kind: readEnum(
record.metric_kind,
radarMetricKinds,
`${path}.metric_kind`,
),
metric_version: readNonEmptyString(
record.metric_version,
`${path}.metric_version`,
),
implementation_kind: readEnum(
record.implementation_kind,
["independent"],
`${path}.implementation_kind`,
),
unit: readEnum(record.unit, radarMetricUnits, `${path}.unit`),
metric_value: readNullableFiniteNumber(
record.metric_value,
`${path}.metric_value`,
),
quality: readEnum(record.quality, radarMetricQualities, `${path}.quality`),
member_count: readIntegerInRange(
record.member_count,
0,
Number.MAX_SAFE_INTEGER,
`${path}.member_count`,
),
valid_sample_count: readIntegerInRange(
record.valid_sample_count,
0,
Number.MAX_SAFE_INTEGER,
`${path}.valid_sample_count`,
),
membership_coverage: readFraction(
record.membership_coverage,
`${path}.membership_coverage`,
),
moneyflow_coverage: readFraction(
record.moneyflow_coverage,
`${path}.moneyflow_coverage`,
),
rank_position: readNullableInteger(
record.rank_position,
1,
`${path}.rank_position`,
),
rank_percentile: readNullablePositiveNumber(
record.rank_percentile,
100,
`${path}.rank_percentile`,
),
rank_change_days: readIntegerInRange(
record.rank_change_days,
1,
5,
`${path}.rank_change_days`,
),
rank_change: readNullableInteger(
record.rank_change,
Number.MIN_SAFE_INTEGER,
`${path}.rank_change`,
),
}
}
function readRecord(value: unknown, path: string): JsonRecord {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw contractError(path, "must be an object")
}
return value as JsonRecord
}
function readArray(value: unknown, path: string): unknown[] {
if (!Array.isArray(value)) throw contractError(path, "must be an array")
return value
}
function readEnum<const Values extends readonly string[]>(
value: unknown,
allowed: Values,
path: string,
): Values[number] {
if (typeof value !== "string" || !allowed.includes(value)) {
throw contractError(path, `must be one of ${allowed.join(", ")}`)
}
return value as Values[number]
}
function readNonEmptyString(value: unknown, path: string): string {
if (typeof value !== "string" || value.trim().length === 0) {
throw contractError(path, "must be a non-empty string")
}
return value
}
function readNullableString(value: unknown, path: string): string | null {
if (value === null) return null
if (typeof value !== "string")
throw contractError(path, "must be a string or null")
return value
}
function readDate(value: unknown, path: string): string {
const text = readNonEmptyString(value, path)
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text)
if (!match) throw contractError(path, "must be an ISO trade date")
const parsed = new Date(`${text}T00:00:00Z`)
if (
Number.isNaN(parsed.getTime()) ||
parsed.getUTCFullYear() !== Number(match[1]) ||
parsed.getUTCMonth() + 1 !== Number(match[2]) ||
parsed.getUTCDate() !== Number(match[3])
) {
throw contractError(path, "must be a valid trade date")
}
return text
}
function readNullableDate(value: unknown, path: string): string | null {
return value === null ? null : readDate(value, path)
}
function readDateTime(value: unknown, path: string): string {
const text = readNonEmptyString(value, path)
if (!Number.isFinite(Date.parse(text))) {
throw contractError(path, "must be an ISO date-time")
}
return text
}
function readNullableDateTime(value: unknown, path: string): string | null {
return value === null ? null : readDateTime(value, path)
}
function readFiniteNumber(value: unknown, path: string): number {
const number =
typeof value === "number"
? value
: typeof value === "string" && value.trim().length > 0
? Number(value)
: Number.NaN
if (!Number.isFinite(number)) throw contractError(path, "must be finite")
return number
}
function readNullableFiniteNumber(value: unknown, path: string): number | null {
return value === null ? null : readFiniteNumber(value, path)
}
function readFraction(value: unknown, path: string): number {
const number = readFiniteNumber(value, path)
if (number < 0 || number > 1)
throw contractError(path, "must be between 0 and 1")
return number
}
function readIntegerInRange(
value: unknown,
min: number,
max: number,
path: string,
): number {
const number = readFiniteNumber(value, path)
if (!Number.isInteger(number) || number < min || number > max) {
throw contractError(path, `must be an integer between ${min} and ${max}`)
}
return number
}
function readNullableInteger(
value: unknown,
min: number,
path: string,
): number | null {
if (value === null) return null
return readIntegerInRange(value, min, Number.MAX_SAFE_INTEGER, path)
}
function readNullablePositiveNumber(
value: unknown,
max: number,
path: string,
): number | null {
if (value === null) return null
const number = readFiniteNumber(value, path)
if (number <= 0 || number > max) {
throw contractError(path, `must be greater than 0 and at most ${max}`)
}
return number
}
function contractError(path: string, reason: string): Error {
return new Error(`Invalid sector radar response: ${path} ${reason}`)
}
@@ -0,0 +1,71 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
const useQuery = vi.hoisted(() => vi.fn())
const api = vi.hoisted(() => ({
getSectorRadarDates: vi.fn(),
getSectorRadarRankings: vi.fn(),
}))
vi.mock("@tanstack/react-query", () => ({ useQuery }))
vi.mock("./sector-radar.api", () => api)
import {
sectorRadarDatesQueryKey,
sectorRadarRankingsQueryKey,
useSectorRadarDates,
useSectorRadarRankings,
} from "./sector-radar.query"
const query = {
tradeDate: "2026-08-28",
sectorType: "industry" as const,
view: "rank_change" as const,
rankChangeMetric: "ratio" as const,
rankChangeDays: 3,
side: "bottom" as const,
search: "银行",
page: 2,
pageSize: 50,
}
describe("sector radar query hooks", () => {
beforeEach(() => {
vi.clearAllMocks()
useQuery.mockImplementation((options) => options)
})
it("keeps every server-affecting filter in the ranking query key", () => {
expect(sectorRadarDatesQueryKey).toEqual(["sectorRadar", "dates"])
expect(sectorRadarRankingsQueryKey(query)).toEqual([
"sectorRadar",
"rankings",
"2026-08-28",
"industry",
"rank_change",
"ratio",
3,
"bottom",
"银行",
2,
50,
])
})
it("forwards React Query cancellation signals to both adapters", async () => {
useSectorRadarDates()
useSectorRadarRankings(query)
const datesOptions = useQuery.mock.calls[0]?.[0] as {
queryFn: (context: { signal: AbortSignal }) => unknown
}
const rankingsOptions = useQuery.mock.calls[1]?.[0] as {
queryFn: (context: { signal: AbortSignal }) => unknown
}
const signal = new AbortController().signal
await datesOptions.queryFn({ signal })
await rankingsOptions.queryFn({ signal })
expect(api.getSectorRadarDates).toHaveBeenCalledWith(signal)
expect(api.getSectorRadarRankings).toHaveBeenCalledWith(query, signal)
})
})
@@ -0,0 +1,35 @@
import { useQuery } from "@tanstack/react-query"
import { getSectorRadarDates, getSectorRadarRankings } from "./sector-radar.api"
import type { RadarRankingsQuery } from "./sector-radar.types"
export const sectorRadarDatesQueryKey = ["sectorRadar", "dates"] as const
export const sectorRadarRankingsQueryKey = (query: RadarRankingsQuery) =>
[
"sectorRadar",
"rankings",
query.tradeDate ?? "latest",
query.sectorType,
query.view,
query.rankChangeMetric,
query.rankChangeDays,
query.side,
query.search ?? "",
query.page,
query.pageSize,
] as const
export function useSectorRadarDates() {
return useQuery({
queryFn: ({ signal }) => getSectorRadarDates(signal),
queryKey: sectorRadarDatesQueryKey,
})
}
export function useSectorRadarRankings(query: RadarRankingsQuery) {
return useQuery({
queryFn: ({ signal }) => getSectorRadarRankings(query, signal),
queryKey: sectorRadarRankingsQueryKey(query),
})
}
@@ -0,0 +1,113 @@
export const sectorTypes = ["concept", "industry"] as const
export type SectorType = (typeof sectorTypes)[number]
export const radarViews = ["amount", "ratio", "swing", "rank_change"] as const
export type RadarView = (typeof radarViews)[number]
export const radarMetricKinds = ["amount", "ratio", "swing"] as const
export type RadarMetricKind = (typeof radarMetricKinds)[number]
export const radarRankSides = ["top", "bottom", "all"] as const
export type RadarRankSide = (typeof radarRankSides)[number]
export const radarPublicationStatuses = [
"running",
"success",
"partial",
"failed",
] as const
export type RadarPublicationStatus = (typeof radarPublicationStatuses)[number]
export const radarMetricQualities = [
"available",
"available_limited_sample",
"unavailable",
] as const
export type RadarMetricQuality = (typeof radarMetricQualities)[number]
export const radarMetricUnits = ["CNY_100M", "ratio"] as const
export type RadarMetricUnit = (typeof radarMetricUnits)[number]
export interface RadarPublication {
publication_id: string
target_trade_date: string
status: RadarPublicationStatus
source_version: string
universe_version: string
metric_versions: string[]
input_hash: string | null
coverage: number
started_at: string
finished_at: string | null
error_summary: string | null
}
export interface RadarDatesResponse {
status: "success" | "no_data"
available_dates: string[]
current_attempt: RadarPublication | null
last_good: RadarPublication | null
}
export interface RadarMetricDefinition {
metric_kind: RadarMetricKind
metric_version: string
label: string
unit: RadarMetricUnit
implementation_kind: "independent"
disclaimer: string
}
export interface RadarRankingRow {
trade_date: string
sector_type: SectorType
sector_code: string
sector_name: string
metric_kind: RadarMetricKind
metric_version: string
implementation_kind: "independent"
unit: RadarMetricUnit
metric_value: number | null
quality: RadarMetricQuality
member_count: number
valid_sample_count: number
membership_coverage: number
moneyflow_coverage: number
rank_position: number | null
rank_percentile: number | null
rank_change_days: number
rank_change: number | null
}
export interface RadarRankingsResponse {
status: "success" | "no_data"
requested_trade_date: string | null
sector_type: SectorType
view: RadarView
rank_change_metric: RadarMetricKind
rank_change_days: number
side: RadarRankSide
search: string | null
publication: RadarPublication | null
definition: RadarMetricDefinition
page: number
page_size: number
total: number
rows: RadarRankingRow[]
}
export interface RadarRankingsQuery {
tradeDate?: string
sectorType: SectorType
view: RadarView
rankChangeMetric: RadarMetricKind
rankChangeDays: number
side: RadarRankSide
search?: string
page: number
pageSize: number
}
export interface SectorRadarRouteSearch extends RadarRankingsQuery {
search: string
}
@@ -0,0 +1,330 @@
import { fireEvent, render, screen } from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import type {
RadarDatesResponse,
RadarRankingsResponse,
SectorRadarRouteSearch,
} from "../api/sector-radar.types"
import { SectorRadarPage } from "./sector-radar-page"
const navigate = vi.hoisted(() => vi.fn())
const useSectorRadarDates = vi.fn()
const useSectorRadarRankings = vi.fn()
const refetchDates = vi.fn()
const refetchRankings = vi.fn()
let routeSearch: SectorRadarRouteSearch
vi.mock("@tanstack/react-router", () => ({
useNavigate: () => navigate,
useSearch: () => routeSearch,
}))
vi.mock("@/features/sector-radar/api/sector-radar.query", () => ({
useSectorRadarDates: () => useSectorRadarDates(),
useSectorRadarRankings: (...args: unknown[]) =>
useSectorRadarRankings(...args),
}))
const successPublication = {
publication_id: "publication-success",
target_trade_date: "2026-08-28",
status: "success" as const,
source_version: "tushare-pro-v1",
universe_version: "eastmoney-dc-v1",
metric_versions: ["zhixing_amount_net_bn_v1"],
input_hash: "a".repeat(64),
coverage: 0.99,
started_at: "2026-08-28T08:00:00Z",
finished_at: "2026-08-28T08:05:00Z",
error_summary: null,
}
const datesResponse: RadarDatesResponse = {
status: "success",
available_dates: ["2026-08-28", "2026-08-27"],
current_attempt: successPublication,
last_good: successPublication,
}
const rankingsResponse: RadarRankingsResponse = {
status: "success",
requested_trade_date: null,
sector_type: "concept",
view: "amount",
rank_change_metric: "amount",
rank_change_days: 1,
side: "all",
search: null,
publication: successPublication,
definition: {
metric_kind: "amount",
metric_version: "zhixing_amount_net_bn_v1",
label: "主力净流入(知行独立实现)",
unit: "CNY_100M",
implementation_kind: "independent",
disclaimer: "知行独立实现,非 OneChartLab 原站公式。",
},
page: 1,
page_size: 20,
total: 2,
rows: [
{
trade_date: "2026-08-28",
sector_type: "concept",
sector_code: "BK0001.DC",
sector_name: "机器人",
metric_kind: "amount",
metric_version: "zhixing_amount_net_bn_v1",
implementation_kind: "independent",
unit: "CNY_100M",
metric_value: 12.5,
quality: "available",
member_count: 20,
valid_sample_count: 19,
membership_coverage: 1,
moneyflow_coverage: 0.95,
rank_position: 1,
rank_percentile: 100,
rank_change_days: 1,
rank_change: 3,
},
{
trade_date: "2026-08-28",
sector_type: "concept",
sector_code: "BK0002.DC",
sector_name: "低空经济",
metric_kind: "amount",
metric_version: "zhixing_amount_net_bn_v1",
implementation_kind: "independent",
unit: "CNY_100M",
metric_value: null,
quality: "available_limited_sample",
member_count: 4,
valid_sample_count: 3,
membership_coverage: 1,
moneyflow_coverage: 0.75,
rank_position: null,
rank_percentile: null,
rank_change_days: 1,
rank_change: null,
},
],
}
describe("SectorRadarPage", () => {
beforeEach(() => {
routeSearch = {
tradeDate: undefined,
sectorType: "concept",
view: "amount",
rankChangeMetric: "amount",
rankChangeDays: 1,
side: "all",
search: "",
page: 1,
pageSize: 20,
}
useSectorRadarDates.mockReturnValue({
data: datesResponse,
isError: false,
isFetching: false,
isPending: false,
refetch: refetchDates,
})
useSectorRadarRankings.mockReturnValue({
data: rankingsResponse,
isError: false,
isFetching: false,
isPending: false,
refetch: refetchRankings,
})
navigate.mockReset()
refetchDates.mockReset()
refetchRankings.mockReset()
})
it("renders independent metric provenance, units, quality, and ranking rows", () => {
render(<SectorRadarPage />)
expect(screen.getAllByText("知行独立实现").length).toBeGreaterThan(0)
expect(screen.getByText("zhixing_amount_net_bn_v1")).toBeInTheDocument()
expect(screen.getByText("12.5 亿元")).toBeInTheDocument()
expect(screen.getByText("机器人")).toBeInTheDocument()
expect(screen.getByText("样本有限")).toBeInTheDocument()
expect(screen.getByText("95%")).toBeInTheDocument()
})
it("stores search and pagination changes in router search state", () => {
render(<SectorRadarPage />)
fireEvent.change(screen.getByRole("searchbox", { name: "搜索板块" }), {
target: { value: "机器人" },
})
fireEvent.click(screen.getByRole("button", { name: "第 1 页" }))
const searchCall = navigate.mock.calls[0]?.[0].search as (
previous: SectorRadarRouteSearch,
) => SectorRadarRouteSearch
expect(searchCall({ ...routeSearch, page: 3 })).toMatchObject({
page: 1,
search: "机器人",
})
})
it("shows rank changes without inventing missing history", () => {
routeSearch = { ...routeSearch, view: "rank_change", rankChangeDays: 5 }
useSectorRadarRankings.mockReturnValue({
data: {
...rankingsResponse,
view: "rank_change",
rank_change_days: 5,
rows: rankingsResponse.rows.map((row, index) => ({
...row,
rank_change_days: 5,
rank_change: index === 0 ? 3 : null,
})),
},
isError: false,
isFetching: false,
isPending: false,
refetch: refetchRankings,
})
render(<SectorRadarPage />)
expect(
screen.getByRole("columnheader", { name: "5 日排名变化" }),
).toBeInTheDocument()
expect(screen.getByText("+3")).toBeInTheDocument()
expect(screen.getByText("暂无可比历史")).toBeInTheDocument()
})
it("warns when a partial attempt has not replaced last-good", () => {
useSectorRadarDates.mockReturnValue({
data: {
...datesResponse,
current_attempt: {
...successPublication,
publication_id: "publication-partial",
target_trade_date: "2026-08-29",
status: "partial",
coverage: 0.8,
input_hash: null,
},
},
isError: false,
isFetching: false,
isPending: false,
refetch: refetchDates,
})
render(<SectorRadarPage />)
expect(screen.getByRole("status")).toHaveTextContent("新一期数据不完整")
expect(screen.getByRole("status")).toHaveTextContent(
"当前仍展示最近有效发布 2026-08-28",
)
})
it("keeps the last response visible while React Query refreshes it", () => {
useSectorRadarRankings.mockReturnValue({
data: rankingsResponse,
isError: false,
isFetching: true,
isPending: false,
refetch: refetchRankings,
})
render(<SectorRadarPage />)
expect(screen.getByRole("status")).toHaveTextContent(
"当前继续展示上一次已读取的有效结果",
)
expect(screen.getByText("机器人")).toBeInTheDocument()
})
it("keeps stale data visible when a background refresh fails", () => {
useSectorRadarRankings.mockReturnValue({
data: rankingsResponse,
isError: true,
isFetching: false,
isPending: false,
refetch: refetchRankings,
})
render(<SectorRadarPage />)
expect(screen.getByRole("status")).toHaveTextContent("雷达数据刷新失败")
expect(screen.getByText("机器人")).toBeInTheDocument()
expect(screen.queryByText("板块资金雷达暂时不可用")).not.toBeInTheDocument()
})
it("renders loading, error with retry, and no-data states", () => {
useSectorRadarDates.mockReturnValue({
data: undefined,
isError: false,
isFetching: true,
isPending: true,
refetch: refetchDates,
})
useSectorRadarRankings.mockReturnValue({
data: undefined,
isError: false,
isFetching: true,
isPending: true,
refetch: refetchRankings,
})
const { rerender } = render(<SectorRadarPage />)
expect(screen.getByLabelText("正在加载板块资金雷达")).toBeInTheDocument()
useSectorRadarDates.mockReturnValue({
data: undefined,
isError: true,
isFetching: false,
isPending: false,
refetch: refetchDates,
})
useSectorRadarRankings.mockReturnValue({
data: undefined,
isError: true,
isFetching: false,
isPending: false,
refetch: refetchRankings,
})
rerender(<SectorRadarPage />)
fireEvent.click(screen.getByRole("button", { name: "重试" }))
expect(refetchDates).toHaveBeenCalledOnce()
expect(refetchRankings).toHaveBeenCalledOnce()
useSectorRadarDates.mockReturnValue({
data: {
status: "no_data",
available_dates: [],
current_attempt: null,
last_good: null,
},
isError: false,
isFetching: false,
isPending: false,
refetch: refetchDates,
})
useSectorRadarRankings.mockReturnValue({
data: {
...rankingsResponse,
status: "no_data",
publication: null,
rows: [],
total: 0,
},
isError: false,
isFetching: false,
isPending: false,
refetch: refetchRankings,
})
rerender(<SectorRadarPage />)
expect(screen.getByText("暂无板块资金发布")).toBeInTheDocument()
})
})
@@ -0,0 +1,648 @@
import { AlertTriangle, Database, RefreshCw } from "lucide-react"
import { useEffect } from "react"
import { useNavigate, useSearch } from "@tanstack/react-router"
import { PageLayout } from "@/app/layout/page-layout"
import {
useSectorRadarDates,
useSectorRadarRankings,
} from "@/features/sector-radar/api/sector-radar.query"
import type {
RadarMetricQuality,
RadarMetricUnit,
RadarPublication,
RadarRankingRow,
RadarRankingsQuery,
SectorRadarRouteSearch,
} from "@/features/sector-radar/api/sector-radar.types"
import { Badge } from "@/shared/ui/badge"
import { Button } from "@/shared/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/ui/card"
import { Input } from "@/shared/ui/input"
import { Pagination } from "@/shared/ui/pagination"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/ui/select"
import { Skeleton } from "@/shared/ui/skeleton"
const PAGE_SIZE_OPTIONS = [10, 20, 50] as const
const sectorTypeOptions = [
{ label: "概念板块", value: "concept" },
{ label: "行业板块", value: "industry" },
] as const
const viewOptions = [
{ label: "主力净额", value: "amount" },
{ label: "单日资金率", value: "ratio" },
{ label: "3—10 日波段资金率", value: "swing" },
{ label: "排名变化", value: "rank_change" },
] as const
const metricOptions = [
{ label: "主力净额", value: "amount" },
{ label: "单日资金率", value: "ratio" },
{ label: "3—10 日波段资金率", value: "swing" },
] as const
const sideOptions = [
{ label: "全部板块", value: "all" },
{ label: "强榜", value: "top" },
{ label: "弱榜", value: "bottom" },
] as const
const rankChangeDayOptions = [1, 2, 3, 4, 5].map((value) => ({
label: `${value} 日变化`,
value: String(value),
}))
export function SectorRadarPage() {
const search = useSearch({ from: "/_workspace/sector-radar" })
const navigate = useNavigate({ from: "/sector-radar" })
const query: RadarRankingsQuery = {
tradeDate: search.tradeDate,
sectorType: search.sectorType,
view: search.view,
rankChangeMetric: search.rankChangeMetric,
rankChangeDays: search.rankChangeDays,
side: search.side,
search: search.search || undefined,
page: search.page,
pageSize: search.pageSize,
}
const dates = useSectorRadarDates()
const rankings = useSectorRadarRankings(query)
const total = rankings.data?.total ?? 0
const pageCount = Math.max(1, Math.ceil(total / search.pageSize))
useEffect(() => {
if (rankings.data?.status === "success" && search.page > pageCount) {
void navigate({
search: (previous) => ({ ...previous, page: pageCount }),
})
}
}, [navigate, pageCount, rankings.data?.status, search.page])
function updateSearch(next: Partial<SectorRadarRouteSearch>) {
void navigate({ search: (previous) => ({ ...previous, ...next }) })
}
const loading = dates.isPending || rankings.isPending
const refreshing =
!loading && (Boolean(dates.isFetching) || Boolean(rankings.isFetching))
const fatalError =
(dates.isError && !dates.data) || (rankings.isError && !rankings.data)
const refreshFailed =
!fatalError && (Boolean(dates.isError) || Boolean(rankings.isError))
const noData =
dates.data?.status === "no_data" || rankings.data?.status === "no_data"
return (
<PageLayout
actions={
<RadarFilters
availableDates={dates.data?.available_dates ?? []}
onChange={updateSearch}
search={search}
/>
}
mode="bounded-workspace"
>
<div className="mx-auto flex min-h-0 w-full max-w-7xl flex-1 flex-col gap-3 md:overflow-hidden">
{loading ? <RadarLoading /> : null}
{!loading && fatalError ? (
<RadarError
onRetry={() => {
void dates.refetch()
void rankings.refetch()
}}
/>
) : null}
{!loading && !fatalError && noData ? <RadarNoData /> : null}
{!loading &&
!fatalError &&
!noData &&
dates.data &&
rankings.data?.status === "success" &&
rankings.data.publication ? (
<>
{refreshFailed ? (
<RadarRefreshFailureStatus />
) : refreshing ? (
<RadarRefreshStatus />
) : null}
<RadarStatusSummary
currentAttempt={dates.data.current_attempt}
lastGood={dates.data.last_good}
publication={rankings.data.publication}
disclaimer={rankings.data.definition.disclaimer}
metricVersion={rankings.data.definition.metric_version}
/>
<RadarTable
onPageChange={(page) => updateSearch({ page })}
onPageSizeChange={(pageSize) =>
updateSearch({ page: 1, pageSize })
}
page={Math.min(search.page, pageCount)}
pageSize={search.pageSize}
response={rankings.data}
/>
</>
) : null}
</div>
</PageLayout>
)
}
interface RadarFiltersProps {
availableDates: string[]
onChange: (next: Partial<SectorRadarRouteSearch>) => void
search: SectorRadarRouteSearch
}
function RadarFilters({ availableDates, onChange, search }: RadarFiltersProps) {
const dateOptions = [
{ label: "最近有效交易日", value: "latest" },
...availableDates.map((value) => ({ label: value, value })),
]
return (
<div aria-label="板块资金雷达筛选" className="grid gap-2 lg:grid-cols-12">
<FilterSelect
className="lg:col-span-2"
label="交易日"
options={dateOptions}
value={search.tradeDate ?? "latest"}
onValueChange={(value) =>
onChange({
page: 1,
tradeDate: value === "latest" ? undefined : value,
})
}
/>
<FilterSelect
className="lg:col-span-2"
label="板块类型"
options={sectorTypeOptions}
value={search.sectorType}
onValueChange={(value) =>
onChange({
page: 1,
sectorType: value as SectorRadarRouteSearch["sectorType"],
})
}
/>
<FilterSelect
className="lg:col-span-2"
label="指标视角"
options={viewOptions}
value={search.view}
onValueChange={(value) =>
onChange({ page: 1, view: value as SectorRadarRouteSearch["view"] })
}
/>
{search.view === "rank_change" ? (
<>
<FilterSelect
className="lg:col-span-2"
label="变化指标"
options={metricOptions}
value={search.rankChangeMetric}
onValueChange={(value) =>
onChange({
page: 1,
rankChangeMetric:
value as SectorRadarRouteSearch["rankChangeMetric"],
})
}
/>
<FilterSelect
className="lg:col-span-2"
label="对比区间"
options={rankChangeDayOptions}
value={String(search.rankChangeDays)}
onValueChange={(value) =>
onChange({ page: 1, rankChangeDays: Number(value) })
}
/>
</>
) : null}
<FilterSelect
className="lg:col-span-2"
label="榜单范围"
options={sideOptions}
value={search.side}
onValueChange={(value) =>
onChange({ page: 1, side: value as SectorRadarRouteSearch["side"] })
}
/>
<label className="grid gap-1 text-xs font-medium text-muted-foreground lg:col-span-2">
搜索板块
<Input
aria-label="搜索板块"
className="bg-background"
maxLength={100}
onChange={(event) =>
onChange({ page: 1, search: event.target.value })
}
placeholder="名称或代码"
type="search"
value={search.search}
/>
</label>
</div>
)
}
function FilterSelect({
className,
label,
onValueChange,
options,
value,
}: {
className?: string
label: string
onValueChange: (value: string) => void
options: readonly { label: string; value: string }[]
value: string
}) {
return (
<label
className={`grid gap-1 text-xs font-medium text-muted-foreground ${className ?? ""}`}
>
{label}
<Select
items={options}
onValueChange={(next) => {
if (typeof next === "string") onValueChange(next)
}}
value={value}
>
<SelectTrigger aria-label={label} className="bg-background">
<SelectValue placeholder={label} />
</SelectTrigger>
<SelectContent align="start" alignItemWithTrigger={false}>
<SelectGroup>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</label>
)
}
function RadarStatusSummary({
currentAttempt,
disclaimer,
lastGood,
metricVersion,
publication,
}: {
currentAttempt: RadarPublication | null
disclaimer: string
lastGood: RadarPublication | null
metricVersion: string
publication: RadarPublication
}) {
const degradedAttempt =
currentAttempt &&
currentAttempt.status !== "success" &&
currentAttempt.publication_id !== lastGood?.publication_id
return (
<Card className="shrink-0 shadow-none">
<CardHeader className="gap-3 p-4 pb-3">
<div className="flex flex-wrap items-start gap-2">
<div className="min-w-0 flex-1">
<CardTitle className="text-base">收盘后板块资金排名</CardTitle>
<CardDescription className="mt-1">{disclaimer}</CardDescription>
</div>
<Badge variant="outline">知行独立实现</Badge>
<Badge variant="outline">{metricVersion}</Badge>
</div>
{degradedAttempt ? (
<div
role="status"
className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-800 dark:text-amber-200"
>
{attemptStatusLabel(currentAttempt.status)}:目标交易日{" "}
{currentAttempt.target_trade_date}
,当前仍展示最近有效发布{" "}
{lastGood?.target_trade_date ?? publication.target_trade_date}。
</div>
) : null}
</CardHeader>
<CardContent className="grid gap-3 p-4 pt-0 text-xs sm:grid-cols-2 lg:grid-cols-5">
<SummaryItem label="数据交易日" value={publication.target_trade_date} />
<SummaryItem
label="发布完成"
value={formatDateTime(publication.finished_at)}
/>
<SummaryItem
label="全局覆盖率"
value={formatCoverage(publication.coverage)}
/>
<SummaryItem label="事实来源" value={publication.source_version} />
<SummaryItem
label="板块范围版本"
value={publication.universe_version}
/>
</CardContent>
</Card>
)
}
function SummaryItem({ label, value }: { label: string; value: string }) {
return (
<div>
<p className="text-muted-foreground">{label}</p>
<p className="mt-1 break-all font-medium text-foreground tabular-nums">
{value}
</p>
</div>
)
}
function RadarTable({
onPageChange,
onPageSizeChange,
page,
pageSize,
response,
}: {
onPageChange: (page: number) => void
onPageSizeChange: (pageSize: number) => void
page: number
pageSize: number
response: Exclude<
ReturnType<typeof useSectorRadarRankings>["data"],
undefined
>
}) {
const rankChangeView = response.view === "rank_change"
return (
<section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-card">
<div className="min-h-0 min-w-0 flex-1 overflow-auto">
<table className="w-full min-w-[920px] border-collapse text-sm">
<caption className="sr-only">
{response.sector_type === "concept" ? "概念" : "行业"}板块资金排名
</caption>
<thead className="sticky top-0 z-10 bg-muted/95 text-left text-xs text-muted-foreground backdrop-blur">
<tr>
<th className="px-3 py-2.5 font-medium" scope="col">
名次
</th>
<th className="px-3 py-2.5 font-medium" scope="col">
板块
</th>
<th className="px-3 py-2.5 text-right font-medium" scope="col">
{rankChangeView
? `${response.rank_change_days} 日排名变化`
: response.definition.label}
</th>
{rankChangeView ? (
<th className="px-3 py-2.5 text-right font-medium" scope="col">
参考指标值
</th>
) : null}
<th className="px-3 py-2.5 text-right font-medium" scope="col">
排名百分位
</th>
<th className="px-3 py-2.5 text-right font-medium" scope="col">
样本
</th>
<th className="px-3 py-2.5 text-right font-medium" scope="col">
资金覆盖率
</th>
<th className="px-3 py-2.5 font-medium" scope="col">
质量
</th>
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{response.rows.map((row) => (
<RadarTableRow
key={`${row.sector_type}-${row.sector_code}`}
rankChangeView={rankChangeView}
row={row}
/>
))}
{response.rows.length === 0 ? (
<tr>
<td
className="px-3 py-12 text-center text-muted-foreground"
colSpan={rankChangeView ? 8 : 7}
>
没有符合当前筛选条件的板块。
</td>
</tr>
) : null}
</tbody>
</table>
</div>
<Pagination
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
page={page}
pageSize={pageSize}
pageSizeOptions={PAGE_SIZE_OPTIONS}
total={response.total}
/>
</section>
)
}
function RadarTableRow({
rankChangeView,
row,
}: {
rankChangeView: boolean
row: RadarRankingRow
}) {
return (
<tr className="hover:bg-muted/40">
<td className="px-3 py-2.5 font-semibold tabular-nums">
{row.rank_position ?? "—"}
</td>
<td className="px-3 py-2.5">
<p className="font-medium text-foreground">{row.sector_name}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{row.sector_code}
</p>
</td>
<td className="px-3 py-2.5 text-right font-medium tabular-nums">
{rankChangeView
? formatRankChange(row.rank_change)
: formatMetricValue(row.metric_value, row.unit)}
</td>
{rankChangeView ? (
<td className="px-3 py-2.5 text-right tabular-nums text-muted-foreground">
{formatMetricValue(row.metric_value, row.unit)}
</td>
) : null}
<td className="px-3 py-2.5 text-right tabular-nums">
{row.rank_percentile === null
? "—"
: `${formatNumber(row.rank_percentile, 2)}%`}
</td>
<td className="px-3 py-2.5 text-right tabular-nums">
{row.valid_sample_count}/{row.member_count}
</td>
<td className="px-3 py-2.5 text-right tabular-nums">
{formatCoverage(row.moneyflow_coverage)}
</td>
<td className="px-3 py-2.5">
<QualityBadge quality={row.quality} />
</td>
</tr>
)
}
function QualityBadge({ quality }: { quality: RadarMetricQuality }) {
if (quality === "available") return <Badge variant="outline">可用</Badge>
if (quality === "available_limited_sample") {
return (
<Badge
className="border-amber-500/40 text-amber-700 dark:text-amber-200"
variant="outline"
>
样本有限
</Badge>
)
}
return <Badge variant="destructive">不可用</Badge>
}
function RadarLoading() {
return (
<Card aria-label="正在加载板块资金雷达" className="flex-1 shadow-none">
<CardHeader className="gap-3">
<CardTitle className="text-base">正在加载板块资金雷达</CardTitle>
<CardDescription>正在读取最近有效发布和板块排名。</CardDescription>
<Skeleton className="h-5 w-52" />
<Skeleton className="h-4 w-80 max-w-full" />
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 7 }, (_, index) => (
<Skeleton className="h-10 w-full" key={index} />
))}
</CardContent>
</Card>
)
}
function RadarRefreshStatus() {
return (
<div
role="status"
className="shrink-0 rounded-md border border-border bg-muted/60 px-3 py-2 text-sm text-muted-foreground"
>
雷达数据正在刷新,当前继续展示上一次已读取的有效结果。
</div>
)
}
function RadarRefreshFailureStatus() {
return (
<div
role="status"
className="shrink-0 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-800 dark:text-amber-200"
>
雷达数据刷新失败,当前继续展示上一次已读取的有效结果。请稍后重试。
</div>
)
}
function RadarError({ onRetry }: { onRetry: () => void }) {
return (
<Card className="shadow-none">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<AlertTriangle
className="size-5 text-destructive"
aria-hidden="true"
/>
板块资金雷达暂时不可用
</CardTitle>
<CardDescription>
请求或响应校验失败,可能是服务、网络或接口契约暂时异常。
</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={onRetry} variant="outline">
<RefreshCw aria-hidden="true" />
重试
</Button>
</CardContent>
</Card>
)
}
function RadarNoData() {
return (
<Card className="shadow-none">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Database className="size-5 text-primary" aria-hidden="true" />
暂无板块资金发布
</CardTitle>
<CardDescription>
完成第一次收盘后构建并通过完整性门槛后,这里会显示最近有效排名。
</CardDescription>
</CardHeader>
</Card>
)
}
function formatMetricValue(value: number | null, unit: RadarMetricUnit) {
if (value === null) return "—"
return unit === "CNY_100M"
? `${formatNumber(value, 2)} 亿元`
: `${formatNumber(value * 100, 2)}%`
}
function formatRankChange(value: number | null) {
if (value === null) return "暂无可比历史"
if (value > 0) return `+${value}`
return String(value)
}
function formatCoverage(value: number) {
return `${formatNumber(value * 100, 1)}%`
}
function formatNumber(value: number, maximumFractionDigits: number) {
return value.toLocaleString("zh-CN", { maximumFractionDigits })
}
function formatDateTime(value: string | null) {
if (!value) return "—"
return new Intl.DateTimeFormat("zh-CN", {
dateStyle: "medium",
timeStyle: "short",
timeZone: "Asia/Shanghai",
}).format(new Date(value))
}
function attemptStatusLabel(status: RadarPublication["status"]) {
if (status === "running") return "新一期仍在构建"
if (status === "partial") return "新一期数据不完整"
if (status === "failed") return "新一期构建失败"
return "新一期已发布"
}
+91
View File
@@ -3,6 +3,17 @@ import { createRootRoute, createRoute, Outlet } from "@tanstack/react-router"
import { AppLayout } from "@/app/layout/app-layout"
import { ComponentsPreviewPage } from "@/features/components/pages/components-preview-page"
import { HomePage } from "@/features/home/pages/home-page"
import {
radarMetricKinds,
radarRankSides,
radarViews,
sectorTypes,
type RadarMetricKind,
type RadarRankSide,
type RadarView,
type SectorType,
} from "@/features/sector-radar/api/sector-radar.types"
import { SectorRadarPage } from "@/features/sector-radar/pages/sector-radar-page"
import {
selectionCategoryFilters,
type SelectionCategoryFilter,
@@ -26,6 +37,63 @@ const indexRoute = createRoute({
component: HomePage,
})
const sectorRadarRoute = createRoute({
getParentRoute: () => workspaceRoute,
path: "/sector-radar",
validateSearch: (search: Record<string, unknown>) => {
const rawPage = Number(search.page)
const rawPageSize = Number(search.pageSize)
const rawRankChangeDays = Number(search.rankChangeDays)
const page =
Number.isFinite(rawPage) && rawPage >= 1 ? Math.floor(rawPage) : 1
const pageSize = [10, 20, 50].includes(rawPageSize) ? rawPageSize : 20
const rankChangeDays =
Number.isFinite(rawRankChangeDays) &&
rawRankChangeDays >= 1 &&
rawRankChangeDays <= 5
? Math.floor(rawRankChangeDays)
: 1
const searchValue =
typeof search.search === "string" ? search.search.slice(0, 100) : ""
const tradeDate =
typeof search.tradeDate === "string" && isValidTradeDate(search.tradeDate)
? search.tradeDate
: undefined
const sectorType = validatedSearchValue<SectorType>(
search.sectorType,
sectorTypes,
"concept",
)
const view = validatedSearchValue<RadarView>(
search.view,
radarViews,
"amount",
)
const rankChangeMetric = validatedSearchValue<RadarMetricKind>(
search.rankChangeMetric,
radarMetricKinds,
"amount",
)
const side = validatedSearchValue<RadarRankSide>(
search.side,
radarRankSides,
"all",
)
return {
tradeDate,
sectorType,
view,
rankChangeMetric,
rankChangeDays,
side,
search: searchValue,
page,
pageSize,
}
},
component: SectorRadarPage,
})
const selectionRoute = createRoute({
getParentRoute: () => workspaceRoute,
path: "/selection",
@@ -64,8 +132,31 @@ const componentsRoute = createRoute({
export const routeTree = rootRoute.addChildren([
workspaceRoute.addChildren([
indexRoute,
sectorRadarRoute,
selectionRoute,
syncRoute,
componentsRoute,
]),
])
function validatedSearchValue<Value extends string>(
value: unknown,
allowed: readonly Value[],
fallback: Value,
): Value {
return typeof value === "string" && allowed.includes(value as Value)
? (value as Value)
: fallback
}
function isValidTradeDate(value: string) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value)
if (!match) return false
const parsed = new Date(`${value}T00:00:00Z`)
return (
!Number.isNaN(parsed.getTime()) &&
parsed.getUTCFullYear() === Number(match[1]) &&
parsed.getUTCMonth() + 1 === Number(match[2]) &&
parsed.getUTCDate() === Number(match[3])
)
}