Merge branch 'codex/zijin' into develop
This commit is contained in:
@@ -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` 三方对账;这些作为后续增量,不阻塞主榜生产。
|
||||
+32
@@ -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` 复用公开确认算法。
|
||||
+35
@@ -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": {}
|
||||
}
|
||||
@@ -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` |
|
||||
|
||||
@@ -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
@@ -50,3 +50,17 @@ _Avoid_: 用当前市值回填历史、把当前目标股票池当作无幸存
|
||||
**股票更新失败**:一只股票未能在目标交易日同时具备日线行情和估值快照时的状态,并应保留可读的失败原因。
|
||||
|
||||
**数据覆盖率**:目标交易日内,有效选股股票数占当前目标股票池目标数的比例,用于判断选股结果是否具备足够完整性。
|
||||
|
||||
## 板块资金雷达
|
||||
|
||||
**板块资金雷达**:在交易日收盘后,分别对概念板块和行业板块的资金指标进行横截面比较、排名与历史变化分析;它不是盘中实时信号,也不构成交易指令。
|
||||
|
||||
**板块排名池**:同一交易日、同一板块类型中参加同一指标排名的板块集合;概念板块与行业板块属于不同排名池,成员数量随交易日变化。
|
||||
|
||||
**板块成员快照**:数据源在指定交易日给出的板块与股票成员关系。历史分析只使用对应交易日的快照,缺失时标记成员未知,不用当前成员替代。
|
||||
|
||||
**独立指标策略**:知行系统根据 Tushare 原始事实自行定义的资金指标算法;它必须有稳定名称和版本,不能称为外部站点未公开公式的复刻。
|
||||
|
||||
**雷达发布批次**:针对一个目标交易日完成的输入采集、质量校验、指标计算和排名发布;同一交易日可以因上游修订产生多个批次。
|
||||
|
||||
**最近有效发布**:最近一个通过完整性与质量门的雷达发布批次。新批次失败或只完成部分数据时,读取端继续使用该批次并明确显示数据已过期。
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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'",
|
||||
)
|
||||
@@ -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)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"] == "概念板块"
|
||||
+59
-37
@@ -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.
|
||||
@@ -320,21 +325,21 @@ 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 |
|
||||
| `{typography.headline}` | 28px | 500 | 1.20 | -0.5px | Pricing tier titles, CTA banner |
|
||||
| `{typography.card-title}` | 22px | 500 | 1.25 | -0.3px | Card title, feature card |
|
||||
| `{typography.subhead}` | 20px | 400 | 1.40 | -0.2px | Lead body, intro paragraphs |
|
||||
| `{typography.body-lg}` | 18px | 400 | 1.50 | -0.1px | Hero subhead, lead paragraphs |
|
||||
| `{typography.body}` | 16px | 400 | 1.50 | 0 | Default body |
|
||||
| `{typography.body-sm}` | 14px | 400 | 1.50 | 0 | Card body, footer |
|
||||
| `{typography.caption}` | 12px | 400 | 1.40 | 0 | Captions, meta |
|
||||
| `{typography.button}` | 15px | 500 | 1.20 | 0 | Button labels |
|
||||
| `{typography.eyebrow}` | 14px | 500 | 1.30 | 0 | Section eyebrow (sentence case) |
|
||||
| `{typography.mono}` | 13px | 400 | 1.50 | 0 | SaansMono for code in mockups |
|
||||
| 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 |
|
||||
| `{typography.headline}` | 28px | 500 | 1.20 | -0.5px | Pricing tier titles, CTA banner |
|
||||
| `{typography.card-title}` | 22px | 500 | 1.25 | -0.3px | Card title, feature card |
|
||||
| `{typography.subhead}` | 20px | 400 | 1.40 | -0.2px | Lead body, intro paragraphs |
|
||||
| `{typography.body-lg}` | 18px | 400 | 1.50 | -0.1px | Hero subhead, lead paragraphs |
|
||||
| `{typography.body}` | 16px | 400 | 1.50 | 0 | Default body |
|
||||
| `{typography.body-sm}` | 14px | 400 | 1.50 | 0 | Card body, footer |
|
||||
| `{typography.caption}` | 12px | 400 | 1.40 | 0 | Captions, meta |
|
||||
| `{typography.button}` | 15px | 500 | 1.20 | 0 | Button labels |
|
||||
| `{typography.eyebrow}` | 14px | 500 | 1.30 | 0 | Section eyebrow (sentence case) |
|
||||
| `{typography.mono}` | 13px | 400 | 1.50 | 0 | SaansMono for code in mockups |
|
||||
|
||||
### Principles
|
||||
|
||||
@@ -370,12 +375,12 @@ 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 |
|
||||
| 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 |
|
||||
| 3 (deep accent) | `{colors.inverse-canvas}` true black | Quote / testimonial callout strip |
|
||||
| 2 (hairline lift) | `{colors.surface-1}` + 1px `{colors.hairline}` border | Floating tiles with extra definition |
|
||||
| 3 (deep accent) | `{colors.inverse-canvas}` true black | Quote / testimonial callout strip |
|
||||
|
||||
Intercom resists drop shadows. Depth is communicated by the white-on-cream surface change.
|
||||
|
||||
@@ -388,16 +393,16 @@ 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 |
|
||||
| `{rounded.lg}` | 6px | Pricing cards, feature cards, FAQ rows |
|
||||
| `{rounded.xl}` | 6px | Product mockup cards |
|
||||
| `{rounded.xxl}` | 6px | Oversized CTA banners |
|
||||
| `{rounded.pill}` | 6px | Tab toggles (legacy alias) |
|
||||
| `{rounded.full}` | 6px | Avatar containers (legacy alias) |
|
||||
| Token | Value | Use |
|
||||
| ---------------- | ----- | -------------------------------------- |
|
||||
| `{rounded.xs}` | 6px | Small chips, badges |
|
||||
| `{rounded.sm}` | 6px | Inline tags |
|
||||
| `{rounded.md}` | 6px | All buttons, form inputs |
|
||||
| `{rounded.lg}` | 6px | Pricing cards, feature cards, FAQ rows |
|
||||
| `{rounded.xl}` | 6px | Product mockup cards |
|
||||
| `{rounded.xxl}` | 6px | Oversized CTA banners |
|
||||
| `{rounded.pill}` | 6px | Tab toggles (legacy alias) |
|
||||
| `{rounded.full}` | 6px | Avatar containers (legacy alias) |
|
||||
|
||||
### Photography & Illustration Geometry
|
||||
|
||||
@@ -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
|
||||
@@ -501,13 +523,13 @@ 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 |
|
||||
| Mobile-Lg | 768px | Pricing comparison becomes accordion; nav hamburger |
|
||||
| Mobile | 480px | Single-column; display-xl scales 72px → ~32px |
|
||||
| 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 |
|
||||
| Mobile-Lg | 768px | Pricing comparison becomes accordion; nav hamburger |
|
||||
| Mobile | 480px | Single-column; display-xl scales 72px → ~32px |
|
||||
|
||||
### Touch Targets
|
||||
|
||||
|
||||
@@ -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: "板块资金雷达",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 "新一期已发布"
|
||||
}
|
||||
@@ -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])
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user