Compare commits

...

15 Commits

Author SHA1 Message Date
yuxuanhui be9c35eadd Implement structural updates and optimizations across multiple modules 2026-09-26 01:19:17 +08:00
yuxuanhui 4af96cee4b chore(task): 删除过时的选股页面迭代计划文档 2026-09-25 23:49:54 +08:00
yuxuanhui ca778fe38f chore: record journal 2026-09-25 23:49:18 +08:00
yuxuanhui 62c66f02ad chore(task): archive 09-07-api-performance-diagnosis 2026-09-25 23:48:22 +08:00
yuxuanhui 1197b78f9a chore(task): archive 09-06-capital-radar-daily-detail 2026-09-25 23:48:21 +08:00
yuxuanhui 39f8fe5fbf chore(task): archive 09-05-selection-layout-sector-filter 2026-09-25 23:48:20 +08:00
yuxuanhui d79e78a411 chore(task): archive 09-04-add-gold-brick-strategy 2026-09-25 23:48:20 +08:00
yuxuanhui a3e51e2dae chore(task): archive 09-01-optimize-stock-list-radar 2026-09-25 23:48:19 +08:00
yuxuanhui fad932b0de feat: Add OneChart score reconstruction research files and validation results
- Introduced new JSON files for normalized and raw inputs, source metadata, and public inputs.
- Added findings document detailing the methodology and results of the score reconstruction.
- Included member differences and worked examples for clarity on data discrepancies.
- Implemented a Python script for reproducing scores based on public inputs.
- Created SQL for raw reaggregation of data.
- Added task metadata for tracking the research completion and validation metrics.
2026-09-25 23:43:53 +08:00
yuxuanhui f020362fb0 fix(deploy): change runner from ubuntu-latest to tencent-prod 2026-09-25 23:43:40 +08:00
yuxuanhui c460ba3524 chore: record journal 2026-09-21 23:58:39 +08:00
yuxuanhui 30bf94c908 chore(task): archive 09-21-radar-weighted-score-rank-change 2026-09-21 23:58:39 +08:00
yuxuanhui b9981aa48d feat(sector-radar): add weighted scores and rank-change views 2026-09-21 23:57:55 +08:00
yuxuanhui 669e89d3c3 feat(sector-radar): add IME-safe sector autocomplete and detail lookup 2026-09-21 22:37:07 +08:00
yuxuanhui 2f1c4f36c2 Support swing radar metrics and sector detail dialogs 2026-09-21 22:28:35 +08:00
111 changed files with 4493 additions and 893 deletions
+2 -2
View File
@@ -8,8 +8,8 @@ on:
jobs: jobs:
deploy: deploy:
runs-on: ubuntu-latest runs-on: tencent-prod
timeout-minutes: 30 timeout-minutes: 60
env: env:
COMPOSE_PROJECT_NAME: zhixing-system COMPOSE_PROJECT_NAME: zhixing-system
@@ -3,7 +3,7 @@
"name": "optimize-stock-list-radar", "name": "optimize-stock-list-radar",
"title": "优化选股列表与资金雷达看板布局", "title": "优化选股列表与资金雷达看板布局",
"description": "", "description": "",
"status": "in_progress", "status": "completed",
"dev_type": null, "dev_type": null,
"scope": null, "scope": null,
"package": null, "package": null,
@@ -11,7 +11,7 @@
"creator": "codex", "creator": "codex",
"assignee": "codex", "assignee": "codex",
"createdAt": "2026-09-01", "createdAt": "2026-09-01",
"completedAt": null, "completedAt": "2026-09-25",
"branch": null, "branch": null,
"base_branch": "main", "base_branch": "main",
"worktree_path": null, "worktree_path": null,
@@ -3,7 +3,7 @@
"name": "add-gold-brick-strategy", "name": "add-gold-brick-strategy",
"title": "新增金砖共振选股策略", "title": "新增金砖共振选股策略",
"description": "按收盘后、qfq、沪深非ST口径新增金砖共振策略,复用B1指标并增加可复制诊断日志。", "description": "按收盘后、qfq、沪深非ST口径新增金砖共振策略,复用B1指标并增加可复制诊断日志。",
"status": "in_progress", "status": "completed",
"dev_type": null, "dev_type": null,
"scope": null, "scope": null,
"package": null, "package": null,
@@ -11,7 +11,7 @@
"creator": "yuxuanhui", "creator": "yuxuanhui",
"assignee": "yuxuanhui", "assignee": "yuxuanhui",
"createdAt": "2026-09-04", "createdAt": "2026-09-04",
"completedAt": null, "completedAt": "2026-09-25",
"branch": null, "branch": null,
"base_branch": "main", "base_branch": "main",
"worktree_path": null, "worktree_path": null,
@@ -3,7 +3,7 @@
"name": "selection-layout-sector-filter", "name": "selection-layout-sector-filter",
"title": "选股页面迭代:布局重构与板块筛选", "title": "选股页面迭代:布局重构与板块筛选",
"description": "", "description": "",
"status": "planning", "status": "completed",
"dev_type": null, "dev_type": null,
"scope": null, "scope": null,
"package": null, "package": null,
@@ -11,7 +11,7 @@
"creator": "yuxuanhui", "creator": "yuxuanhui",
"assignee": "yuxuanhui", "assignee": "yuxuanhui",
"createdAt": "2026-09-05", "createdAt": "2026-09-05",
"completedAt": null, "completedAt": "2026-09-25",
"branch": null, "branch": null,
"base_branch": "main", "base_branch": "main",
"worktree_path": null, "worktree_path": null,
@@ -3,7 +3,7 @@
"name": "capital-radar-daily-detail", "name": "capital-radar-daily-detail",
"title": "资金雷达:单日榜单与板块详情", "title": "资金雷达:单日榜单与板块详情",
"description": "", "description": "",
"status": "in_progress", "status": "completed",
"dev_type": null, "dev_type": null,
"scope": null, "scope": null,
"package": null, "package": null,
@@ -11,7 +11,7 @@
"creator": "yuxuanhui", "creator": "yuxuanhui",
"assignee": "yuxuanhui", "assignee": "yuxuanhui",
"createdAt": "2026-09-06", "createdAt": "2026-09-06",
"completedAt": null, "completedAt": "2026-09-25",
"branch": null, "branch": null,
"base_branch": "main", "base_branch": "main",
"worktree_path": null, "worktree_path": null,
@@ -23,4 +23,4 @@
"relatedFiles": [], "relatedFiles": [],
"notes": "实施及本地验证完成,详见 verification.md。代码按用户边界保持未提交,未自动归档;正常业务库尚未应用本次迁移。", "notes": "实施及本地验证完成,详见 verification.md。代码按用户边界保持未提交,未自动归档;正常业务库尚未应用本次迁移。",
"meta": {} "meta": {}
} }
@@ -3,7 +3,7 @@
"name": "api-performance-diagnosis", "name": "api-performance-diagnosis",
"title": "接口性能诊断与缓存方案评估", "title": "接口性能诊断与缓存方案评估",
"description": "优化资金雷达详情、历史与榜单读取;无 Redis、无迁移,待用户发布验证。", "description": "优化资金雷达详情、历史与榜单读取;无 Redis、无迁移,待用户发布验证。",
"status": "in_progress", "status": "completed",
"dev_type": null, "dev_type": null,
"scope": null, "scope": null,
"package": null, "package": null,
@@ -11,7 +11,7 @@
"creator": "yuxuanhui", "creator": "yuxuanhui",
"assignee": "yuxuanhui", "assignee": "yuxuanhui",
"createdAt": "2026-09-07", "createdAt": "2026-09-07",
"completedAt": null, "completedAt": "2026-09-25",
"branch": "develop", "branch": "develop",
"base_branch": "develop", "base_branch": "develop",
"worktree_path": null, "worktree_path": null,
@@ -23,4 +23,4 @@
"relatedFiles": [], "relatedFiles": [],
"notes": "用户已授权提交本地 develop。226 项测试通过;1 个既有集成测试失败和 14 个既有类型错误均在修改前复现。", "notes": "用户已授权提交本地 develop。226 项测试通过;1 个既有集成测试失败和 14 个既有类型错误均在修改前复现。",
"meta": {} "meta": {}
} }
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
@@ -0,0 +1,9 @@
# 研究设计
这是只读分析任务,不进入产品实现阶段。
公开证据链:页面展示 → 实际引用脚本 → 实际请求的公开数据 → 评分与排名字段。数据库证据链:库表目录 → 字段及单位 → 重叠交易日原始值 → 候选公式复算 → 与公开评分比较。
优先检验可解释的低自由度公式。分开检验资金比率、横截面排序/归一化、时间窗口聚合;用多日和不同板块类型验证,避免单点拟合。识别数据修订、单位换算、成分股聚合与板块原始数据的口径差异。
数据库连接强制 default_transaction_read_only,设置查询超时。仅在本机保留任务所需数据;公开资料可以缓存供复核,凭据不落盘。报告不将无法唯一识别的参数写成确定结论。
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
@@ -0,0 +1,9 @@
# 分析步骤(无产品实现)
- [x] 读取网站公开脚本和数据,记录字段、日期和来源。
- [x] 只读确认数据库版本、数据表及覆盖范围。
- [x] 建立日期、板块和单位映射,对比原始输入。
- [x] 逐层验证单日评分和波段评分候选公式,记录误差。
- [x] 核验关键结论,完成研究记录与用户答复。
验证使用实际数据计算与证据核对;不运行与分析无关的产品测试。任务不包含代码实施、共享知识推广、提交和发布。
@@ -0,0 +1,25 @@
# 还原 OneChart 波段与单日资金流评分
## Goal
分析 https://onechartlab.com/ 板块资金雷达的波段流入率、单日流入率及其加权评分,结合用户本机 PostgreSQL 数据给出可复核的公式证据、复算结果和不确定性。
## Requirements
- 区分原始资金比率、评分和排名,明确时间窗口、权重、标准化、排名池和缺失数据规则。
- 优先读取网站实际公开的 HTML、脚本和数据;不将本项目独立指标策略视为该站点真实公式。
- 数据库仅使用只读连接及有范围限制的 SELECT,先确认可用库、表、字段与日期。
- 使用相同日期、板块标识和数据口径进行多样本验证,记录误差及候选公式可识别性。
- 用户已同意创建任务并记录分析。只修改当前任务记录,不修改产品代码、数据库或共享规格,不提交或发布。
- 凭据不写入任务记录、研究脚本、结果文件或报告;本机数据不传给外部服务。
## Acceptance Criteria
- [x] 列出评分相关公开字段及来源,说明公式是否直接公开。
- [x] 给出单日与波段评分的可验证公式,或明确最有依据的候选公式和未解决参数。
- [x] 用数据库与网站重叠样本核验,报告样本范围、误差和差异原因。
- [x] 保存必要分析记录与可复算证据,最终回答清楚区分事实、推断和限制。
## 结果
公开原始字段可精确重现最近 12 日 9,492 条记录,两种评分最大绝对误差约 3.41e-13;数据库最新日原始快照试算平均误差为单日 0.9783 分、波段 1.9076 分,个别板块仍有较大输入差异。详见 `research/findings.md` 和 `research/database-validation.md`。本研究已完成,没有产品实施待批准。
@@ -0,0 +1,128 @@
{
"normalized_aggregate": {
"joined_rows": 12654,
"joined_dates": 16,
"latest_rows": 791,
"Ratio": {
"checked_rows": 791,
"mean_absolute_error": 4.66428796165067,
"max_absolute_error": 129.76729492953035,
"same_one_decimal_display": 204,
"same_final_rank": 215
},
"Swing": {
"checked_rows": 791,
"mean_absolute_error": 4.803044287121097,
"max_absolute_error": 179.7496672672861,
"same_one_decimal_display": 96,
"same_final_rank": 199
},
"turnover_within_1_01_yuan": 294,
"weight_within_1e_10": 280,
"examples": [
{
"ts_code": "BK0581.DC",
"index_name": "智能电网 (概念)",
"ratio_db": 0.01288923245126,
"weight_db": 1.08086957077924,
"pred_Ratio_Score": 684.0285689472486,
"Ratio_Score": 789.4114202884311,
"pred_Swing_Score": 386.3978175732549,
"Swing_Score": 433.9148866486079
},
{
"ts_code": "BK0615.DC",
"index_name": "中药概念 (概念)",
"ratio_db": 0.080175906138025,
"weight_db": 1.041522634544339,
"pred_Ratio_Score": 1036.4911242325306,
"Ratio_Score": 1036.9806099966886,
"pred_Swing_Score": 825.1676911365778,
"Swing_Score": 823.0404356041679
},
{
"ts_code": "BK0653.DC",
"index_name": "养老概念 (概念)",
"ratio_db": 0.065820711061371,
"weight_db": 1.050692084122948,
"pred_Ratio_Score": 1038.0025661987577,
"Ratio_Score": 1035.4711369170884,
"pred_Swing_Score": 1005.0098195958633,
"Swing_Score": 1005.0161034783504
},
{
"ts_code": "BK1657.DC",
"index_name": "病原体防治 (概念)",
"ratio_db": 0.064807487656449,
"weight_db": 1.05614239070725,
"pred_Ratio_Score": 1040.8359792477243,
"Ratio_Score": 1038.383599887465,
"pred_Swing_Score": 829.0972873909569,
"Swing_Score": 830.4517488043484
}
]
},
"raw_snapshot_reaggregation": {
"joined_rows": 8701,
"joined_dates": 11,
"latest_rows": 791,
"Ratio": {
"checked_rows": 791,
"mean_absolute_error": 0.9783318452555938,
"max_absolute_error": 105.32457821281037,
"same_one_decimal_display": 581,
"same_final_rank": 576
},
"Swing": {
"checked_rows": 791,
"mean_absolute_error": 1.9075528008117222,
"max_absolute_error": 150.16516952571226,
"same_one_decimal_display": 318,
"same_final_rank": 363
},
"turnover_within_1_01_yuan": 610,
"weight_within_1e_10": 496,
"examples": [
{
"ts_code": "BK0581.DC",
"index_name": "智能电网 (概念)",
"ratio_db": 0.012878069596386,
"weight_db": 1.080961651218729,
"pred_Ratio_Score": 684.0868420756208,
"Ratio_Score": 789.4114202884311,
"pred_Swing_Score": 387.73624445889186,
"Swing_Score": 433.9148866486079
},
{
"ts_code": "BK0615.DC",
"index_name": "中药概念 (概念)",
"ratio_db": 0.079794956978515,
"weight_db": 1.041976772408121,
"pred_Ratio_Score": 1036.9430681935887,
"Ratio_Score": 1036.9806099966886,
"pred_Swing_Score": 823.0106390759795,
"Swing_Score": 823.0404356041679
},
{
"ts_code": "BK0653.DC",
"index_name": "养老概念 (概念)",
"ratio_db": 0.065820711061371,
"weight_db": 1.050692084122948,
"pred_Ratio_Score": 1035.4646626139197,
"Ratio_Score": 1035.4711369170884,
"pred_Swing_Score": 1005.0098195958633,
"Swing_Score": 1005.0161034783504
},
{
"ts_code": "BK1657.DC",
"index_name": "病原体防治 (概念)",
"ratio_db": 0.064737394636734,
"weight_db": 1.05622244732493,
"pred_Ratio_Score": 1038.3636136745088,
"Ratio_Score": 1038.383599887465,
"pred_Swing_Score": 829.160133769571,
"Swing_Score": 830.4517488043484
}
]
}
}
@@ -0,0 +1,76 @@
# PostgreSQL 独立核验
## 查询与范围
数据库:`zhixing-system`,PostgreSQL 18.4。本次通过用户授权的 SSH 隧道访问;连接参数强制 `default_transaction_read_only=on`、`statement_timeout=30000`、`lock_timeout=2000`,实测 `transaction_read_only=on`。只执行目录检查和 SELECT/CTE;没有数据库写入。凭据和私钥内容不保存在任务文件中。
读取了以下数据:
- `sector_radar_publication`:为每个交易日选取最近成功发布。
- `sector_radar_daily_aggregate`:2026-08-28 至 2026-09-21,16 个有成功发布的交易日、16,000 行。9 月 7 日没有成功发布对应的汇总,因此未用别日汇总冒充。
- `sector_radar_source_snapshot`:保存的原始 `dc_member`、`daily`、`moneyflow_dc`。对 2026-09-07 至 2026-09-21 的 11 日重新聚合,共 11,000 个板块日;SQL 保存在 `raw-reaggregation.sql`。
- 小范围检查 `sector_radar_stock_fact` 状态,以及四个板块的当日成员快照。
原始快照重聚合按日期和分区选择最近观测,成员使用逐板块分区,股票字段按日期/代码去重;`daily.amount × 1000` 与 `moneyflow_dc.net_amount × 10000` 统一为元。原始重聚合没有沿用产品的沪深 A 股过滤,目的仅是调查网站口径,未改变产品规则。
股票名单、金额数据仅在本机处理,没有传给外部文档查询或其他服务。
## 验证设计
对齐网站实际的日期、板块代码与类型,使用数据库提供的净额与成交额独立计算 r、3/10 日均值及 5 日成交额权重。预测阶段不使用网站的比率、分数或最终排名。
本次试算将数据库聚合成交额向下取整到元,再使用 `r = 净额/(成交额+100)`、`W = log10(1+MA5(成交额))/10`。这是与公开数值关系一致的候选输入口径,不能把该试算本身当作后端代码证据。
计算百分位前特意限制到网站的板块池。数据库有概念 504、行业 496,共 1000 个板块;网站是概念 414、行业 377,共 791 个。即使原始金额相同,在不同 N 和不同成员的池中计算百分位也不能复刻网站分数。
## 最新日结果
2026-09-21 共 791 条对齐记录:
| 数据输入 | 单日评分 MAE | 单日最大误差 | 单日一位小数一致 | 波段评分 MAE | 波段最大误差 | 波段一位小数一致 |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| 当前产品规范化汇总 | 4.6643 | 129.7673 | 204/791 | 4.8030 | 179.7497 | 96/791 |
| 库中原始快照重新聚合 | 0.9783 | 105.3246 | 581/791 | 1.9076 | 150.1652 | 318/791 |
原始快照重聚合后,610/791 个最新日成交额与从网站金额/比率反求的成交额相差不超过 1.01 元;496/791 个近五日成交额权重达到 `1e-10` 内一致。这为“近五日成交额取对数作为权重”提供了不依赖网站评分预测输入的数据库佐证。
实际例子:
| 板块 | 数据库单日复算 | 网站单日分数 | 数据库波段复算 | 网站波段分数 |
| --- | ---: | ---: | ---: | ---: |
| 中药概念 | 1036.9431 | 1036.9806 | 823.0106 | 823.0404 |
| 养老概念 | 1035.4647 | 1035.4711 | 1005.0098 | 1005.0161 |
| 病原体防治 | 1038.3636 | 1038.3836 | 829.1601 | 830.4517 |
| 智能电网 | 684.0868 | 789.4114 | 387.7362 | 433.9149 |
不能只报告平均误差而忽略个别大偏差;数据库原始快照尚未逐板块精确复刻网站输入。本次请求中的评分机制已完成数值还原,但输入采集、板块池和成员版本的完整复刻属于进一步工作。
## 已确认的输入差异
1. **股票范围不同。** 产品 `normalize.py:242` 起要求当前上市、沪深证券,排除北交所/B 股;`facts.py:72` 起只将 `AVAILABLE` 股票累加到板块金额。最新日事实中有 `lifecycle_invalid=435`、`suspended=12`、`available=5209`。改用原始快照后误差显著减少,但没有完全消失。
2. **板块池不同。** 1000 与 791 的差异已在上述比较中控制;真正独立生产还需要明确网站选择这 791 个板块的规则。
3. **公开成员表与数据库当日快照不同。** 按股票代码去除交易所后缀再比较:
| 板块 | 库内成员 | 网站公开成员 | 交集 |
| --- | ---: | ---: | ---: |
| 智能电网 | 197 | 195 | 190 |
| 中药概念 | 146 | 145 | 144 |
| 碳交易 | 142 | 138 | 137 |
| 超跌股 | 167 | 22 | 7 |
智能电网库内独有 `002851/003043/301236/301669/605336/688187/920222`;网站公开表独有 `001388/002063/300140/600522/920375`。完整差集见 `member-differences.json`。网站这份 `CONSTITUENT_MAP` 不是按交易日分片的历史成员证据,不能据此认定所有历史评分都使用同一份名单;这里证明的是输入版本确实存在差异,不宣称它解释了每一分残差。
4. **上游净额也不完全一致。** 即使成交额近似一致,个股资金流按万元提供的小数精度、板块级净额来源、成员与观测时点仍可能造成净额差异。网站页脚同时提及东财板块日线资金流;本数据库没有对应 `moneyflow_ind_dc` 快照,不能将两种来源强行视为逐值相同。
5. **9 月 7 日原始资金流不完整。** 该日重聚合样本的资金流覆盖明显不足,不把它用于声称全部 11 日的评分准确度;最终 9 月 21 日的最近十日窗口从 9 月 8 日开始。
第 1–3 项有直接目录、代码和数值证据;第 4 项中的具体上游精度与发布时间机制尚未取得网站构建端证据,因此保留为差异候选原因。
## 保存与复现
- `db-raw-inputs.json.gz`:原始快照重聚合结果。
- `db-normalized-inputs.json.gz`:当前产品汇总,作为对照。
- `db-source-metadata.json`:库版本、只读设置及各数据源覆盖范围。
- `database_reproduce.py`:只读取固定文件,在对齐的排名池中独立计算。
- `database-validation.json`:精确误差、显示与排名一致数量。
运行 `database_reproduce.py` 不需要数据库凭据或在线连接。所有产物限于当前 Trellis 任务;未修改产品实现、共享规格或生产数据。
@@ -0,0 +1,72 @@
"""使用已读取的 PostgreSQL 金额快照独立算分,无数据库连接和凭据。"""
import gzip
import json
import numpy as np
import pandas as pd
from reproduce import ROOT
def load(name: str) -> list[dict]:
return json.loads(gzip.decompress((ROOT / name).read_bytes()))["rows"]
def compare(public: pd.DataFrame, rows: list[dict], normalized: bool) -> dict:
"""对齐网站实际排名池;评分只使用库内净额和成交额构造。"""
source = pd.DataFrame(rows)
source["ts_code"] = source["sector_code"]
keys = ["trade_date", "ts_code"]
if normalized:
source["type"] = source["sector_type"].map({"concept": "概念板块", "industry": "行业板块"})
keys.append("type")
for field in ["net_amount_yuan", "turnover_yuan"]:
source[field] = pd.to_numeric(source[field])
frame = public.merge(source, on=keys, validate="one_to_one").sort_values(["ts_code", "trade_date"])
frame["amount_db"] = np.floor(frame["turnover_yuan"])
frame["ratio_db"] = frame["net_amount_yuan"] / (frame["amount_db"] + 100)
for field, windows in [("amount_db", [5]), ("ratio_db", [3, 10])]:
for window in windows:
frame[f"{field}{window}"] = frame.groupby("ts_code")[field].transform(
lambda values: values.rolling(window, min_periods=window).mean()
)
frame["weight_db"] = np.log10(frame["amount_db5"] + 1) / 10
groups = frame.groupby(["trade_date", "type"])
frame["pred_Ratio_Score"] = 1000 * groups["ratio_db"].rank(pct=True) * frame["weight_db"]
frame["pred_Swing_Score"] = (
500 * (groups["ratio_db3"].rank(pct=True) + groups["ratio_db10"].rank(pct=True))
* frame["weight_db"]
)
latest = frame[frame["trade_date"].eq("2026-09-21")].copy()
result = {"joined_rows": len(frame), "joined_dates": frame["trade_date"].nunique(), "latest_rows": len(latest)}
for metric in ["Ratio", "Swing"]:
expected, prediction = f"{metric}_Score", f"pred_{metric}_Score"
errors = (latest[prediction] - latest[expected]).abs()
ranks = latest.groupby("type")[prediction].rank(ascending=False)
result[metric] = {
"checked_rows": int(errors.notna().sum()), "mean_absolute_error": errors.mean(),
"max_absolute_error": errors.max(),
"same_one_decimal_display": int(latest[prediction].round(1).eq(latest[expected].round(1)).sum()),
"same_final_rank": int(ranks.eq(latest[f"{metric}_RankPos"]).sum()),
}
# 从网站两个原始字段得到的成交额仅用于末端验证,不参与库内评分预测。
target_turnover = latest["Amount_Raw_BN"] * 1e8 / latest["Ratio_Raw_Pct"] - 100
result["turnover_within_1_01_yuan"] = int(latest["turnover_yuan"].sub(target_turnover).abs().lt(1.01).sum())
website_weight = latest["Ratio_Score"] / (1000 * latest.groupby("type")["Ratio_Raw_Pct"].rank(pct=True))
result["weight_within_1e_10"] = int(latest["weight_db"].sub(website_weight).abs().lt(1e-10).sum())
examples = latest[latest["ts_code"].isin(["BK0615.DC", "BK0653.DC", "BK1657.DC", "BK0581.DC"])][[
"ts_code", "index_name", "ratio_db", "weight_db", "pred_Ratio_Score", "Ratio_Score", "pred_Swing_Score", "Swing_Score"
]]
result["examples"] = json.loads(examples.to_json(orient="records", force_ascii=False, double_precision=15))
return result
if __name__ == "__main__":
public = pd.DataFrame(load("public-inputs.json.gz"))
result = {
"normalized_aggregate": compare(public, load("db-normalized-inputs.json.gz"), True),
"raw_snapshot_reaggregation": compare(public, load("db-raw-inputs.json.gz"), False),
}
(ROOT / "database-validation.json").write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n")
print(json.dumps(result, ensure_ascii=False, indent=2))
@@ -0,0 +1,55 @@
{
"postgres_version": "18.4",
"transaction_read_only": "on",
"sources": [
{
"api_name": "daily",
"first_date": "2026-08-28",
"last_date": "2026-09-21",
"snapshot_count": 17,
"source_rows": 94336
},
{
"api_name": "dc_index",
"first_date": "2026-08-28",
"last_date": "2026-09-21",
"snapshot_count": 34,
"source_rows": 17000
},
{
"api_name": "dc_member",
"first_date": "2026-08-28",
"last_date": "2026-09-21",
"snapshot_count": 17105,
"source_rows": 1678901
},
{
"api_name": "moneyflow",
"first_date": "2026-09-07",
"last_date": "2026-09-21",
"snapshot_count": 11,
"source_rows": 61054
},
{
"api_name": "moneyflow_dc",
"first_date": "2026-08-28",
"last_date": "2026-09-21",
"snapshot_count": 102,
"source_rows": 101285
},
{
"api_name": "suspend_d",
"first_date": "2026-08-28",
"last_date": "2026-09-21",
"snapshot_count": 17,
"source_rows": 193
},
{
"api_name": "trade_cal",
"first_date": "2026-08-28",
"last_date": "2026-09-21",
"snapshot_count": 17,
"source_rows": 787
}
]
}
@@ -0,0 +1,137 @@
# OneChart 波段与单日加权评分还原
研究日期:2026-09-21。这是一次只读算法研究,不包含产品修改或数据库写入。
## 结论与证据等级
已找到一组低自由度、可直接执行的公式,使用网站公开的原始流入率和净额,精确重现 2026-09-04 至 2026-09-21 共 12 个交易日、9,492 条记录的单日评分、波段评分及最终排名。两个评分最大绝对误差均为 `3.410605131648481e-13`,即浮点运算误差。
这是**对观测数据的数值还原**,不是取得网站后端源码。不能据此保证所有历史版本、未来版本及无观测的边界情况都采用相同实现。旧研究未识别出权重的结论不再适用于本次验证区间,但本任务没有修改旧报告或共享规格。
## 公式
对每个板块及日期,定义:
- `r = Ratio_Raw_Pct`,以小数表示的当日流入率,页面显示时乘 100。
- `F = Amount_Raw_BN × 100000000`,主力净流入金额,单位元。虽然字段包含 `BN`,网站实际展示单位是亿元。
- `MA_n(x)`:按板块、日期排序后最近 n 条有效观测的简单均值,包含当日;不擅自补齐公开历史中的缺失记录。稳定验证区间每天都公开了 791 个板块,但部分窗口的前置历史仍有日期缺失。
- `P_t(x)`:**同日、同板块类型**的升序排名百分位,`rank(x)/N`,取值从 `1/N` 到 1;概念和行业各自排名。该排名由原始指标计算,不使用网站最终 `*_RankPct` 作为输入。
### 单日评分
```text
Ratio_Score = 1000 × P_t(r) × W
```
### 波段评分
```text
R3 = MA_3(r)
R10 = MA_10(r)
Swing_Score = 1000 × [0.5 × P_t(R3) + 0.5 × P_t(R10)] × W
```
关键是**先分别求 3 日、10 日流入率均值的横截面排名,再各乘 50%**。如果改成先把两个均值合成波段流入率,再对合成值排名,就会得到不同评分;2026-09-21 该错误方法平均偏差约 62.54 分。
### 页面显示的波段流入率与波段净额
```text
Swing_Ratio_Val = 0.5 × MA_3(r) + 0.5 × MA_10(r)
Swing_Amount_Val = 0.5 × MA_3(Amount_Raw_BN)
+ 0.5 × MA_10(Amount_Raw_BN)
```
所以页面所称“3–10 日多周期协同加权”,在验证数据中可具体化为 **3 日与 10 日两个窗口,各占 50%**。没有证据表明必须引入 4、5、6、7、8、9 日窗口。该波段流入率展开到每日后,最近 3 日每一天占 `13/60 ≈ 21.6667%`,再往前 7 日每一天占 5%;最近三日合计占 65%。这种每日线性展开仅适用于原始波段流入率,不能直接替代带横截面排名的波段评分。
### 成交额权重 W
从公开字段可以精确验证的表达式是:
```text
V_proxy = F / r
W = log10(MA_5(V_proxy) - 99) / 10
```
若定义与网站计算口径对应的成交额 `A = V_proxy - 100`(元),则等价于:
```text
W = log10(1 + MA_5(A)) / 10
r = F / (A + 100)
```
`-99` 由公开数据中的金额/比率关系定位,在 791 个最新日样本中,反求的 5 日成交额与 `MA_5(F/r)` 的差均为约 99 元;使用该修正后,评分误差降至机器精度。单凭公开字段,不能证明后端源码里真的写了“分母加 100 元”,也不能断言这是防零分母常量而不是单位换算产生的等价结果;原始金额、成员和取整口径还需结合数据库核对。
经济含义是用成交活跃程度调节排名得分,采用对数使规模差异的影响较温和。近 5 日平均成交额为 1 亿、10 亿、100 亿、1000 亿元时,W 约为 0.8、0.9、1.0、1.1。因而评分可以超过 1000;它不是限定在 0–1000 的百分制,也不是收益概率。
作为交叉校验,JSON 中虽然未用于单日净额榜默认排序的 `Amount_Score` 也满足:
```text
Amount_Score = 1000 × P_t(Amount_Raw_BN) × W
```
## 最新日计算例子
2026-09-21,概念池 N=414,病原体防治(BK1657.DC):
| 项目 | 数值 |
| --- | ---: |
| 单日流入率 | 6.47373795% |
| 单日流入率原始百分位 | 407/414 = 0.9830917874 |
| 3 日均值的百分位 | 376/414 = 0.9082125604 |
| 10 日均值的百分位 | 275/414 = 0.6642512077 |
| 近 5 日成交额权重 W | 约 1.056243 |
| 单日评分 | 1038.383599887465 |
| 波段评分 | 830.4517488043484 |
| 网站最终单日/波段排名 | 1 / 47 |
```text
单日 = 1000 × (407/414) × W = 1038.3836 → 页面 1038.4
波段 = 1000 × [(376/414 + 275/414)/2] × W = 830.4517 → 页面 830.5
```
它的单日原始流入率并非全池最高,成交额权重加成后,单日综合评分可以排到第一。完整的三板块样例保存在 `worked-examples.json`。
## 验证范围与限制
公开数据总计 23,613 行、30 个交易日,覆盖 2026-08-11 至 2026-09-21。最新日 791 个板块:概念 414、行业 377。
| 验证对象 | 稳定区间样本 | 最大绝对误差 |
| --- | ---: | ---: |
| Ratio_Score | 9,492 | 3.41e-13 |
| Swing_Score | 9,492 | 3.41e-13 |
| Swing_Ratio_Val | 9,492 | 2.78e-17 |
| Swing_Amount_Val(亿元) | 9,492 | 5.68e-14 |
| Amount_Score(交叉校验) | 9,492 | 4.55e-13 |
最终排名按分数降序完全吻合;`RankPct = 100 × (N - RankPos + 1) / N`。金额榜的最终排名依据是原始净额,不是 Amount_Score。
复算过程中仅使用日期、板块代码、类型、原始单日流入率和净额;评分、最终排名只在最后比较时读取。因此没有用答案反过来构造预测输入。主会话执行了 `reproduce.py`;独立代理 `/root/score_formula_audit` 已完成同样输入边界下的核验,结果一致。公开前端取证由 `/root/onechart_public_evidence` 完成。
不能把上述准确度推广到整份 30 日历史:
- 最早 4/9 个观测缺少足够的 5/10 日前置历史,分别无法计算成交额权重/波段窗口。
- 单日评分在 2026-08-17 至 08-26 有差异,最大约 6.680462 分;8 月 27 日起可计算样本吻合。
- 波段评分在 2026-08-24 至 09-03 有差异,最大约 261.703574 分。8 月 27 日公开板块只有 707 条,部分后续窗口的输入不全;早期还存在板块集合或数据修订差异,尚未逐项确认原因。
- 稳定验证区间没有原始比率或分数并列,不能确定后端的并列排名规则。脚本选用 `average` 仅作为明确的复算约定。
- 全量没有 `r=0`,不能由本样本识别零净流入、零成交额、极低流动性和无历史板块的所有边界策略。
## 公开实现证据
- [首页](https://onechartlab.com/) 只读取 `${activeTab}_Score`,提示“后端特征算法算出的综合加权值”;本次缓存 `index.html:800–805`。前端没有公开评分构造函数。
- 首页 `index.html:1032` 说明“3-10 个交易日多周期协同加权”;具体 50%/50% 来自数值检验,而非这句话本身。
- 首页 `index.html:1253–1257` 按板块类型过滤;`1292–1314` 指定 Swing/Ratio 默认按各自分数排序,并用最终 RankPct 切前后 10%。
- [radar_manifest.json](https://onechartlab.com/radar_manifest.json) 列出日期分片;已保存本次 manifest。
- [完整公开 JSON](https://onechartlab.com/radar_data_latest.json) 和 [2026-09-21 分片](https://onechartlab.com/radar_data/dates/2026-09-21.22531a461a8b.json) 给出数值证据。`public-inputs.json.gz` 保存了所需字段和原始文件 SHA-256,避免未来网站更新导致样本改变。
- [Tushare daily](https://tushare.pro/document/2?doc_id=27) 的 amount 单位为千元;[moneyflow_dc](https://tushare.pro/document/2?doc_id=349) 的 net_amount 单位为万元。数据库重聚合分别乘 1000 和 10000 后统一成元。
## 复现
在仓库根目录运行:
```bash
zhixing-server/.venv/bin/python .trellis/tasks/archive/2026-09/09-21-onechart-score-reconstruction/research/reproduce.py
```
脚本读取固定样本,不需要网络或数据库凭据,输出 `public-validation.json`。本次使用 Python 3.12.11、pandas 3.0.5、NumPy 2.5.1。
数据库的独立核对另见本目录 `database-validation.md`;研究 SQL 和结果均与生产代码隔离。
@@ -0,0 +1,246 @@
[
{
"code": "BK0581.DC",
"name": "智能电网",
"db_count": 197,
"site_count": 195,
"intersection_count": 190,
"db_only": [
"002851",
"003043",
"301236",
"301669",
"605336",
"688187",
"920222"
],
"site_only": [
"001388",
"002063",
"300140",
"600522",
"920375"
],
"observed_at": "2026-09-21 10:40:57.972405+00:00"
},
{
"code": "BK0615.DC",
"name": "中药概念",
"db_count": 146,
"site_count": 145,
"intersection_count": 144,
"db_only": [
"000626",
"920367"
],
"site_only": [
"300391"
],
"observed_at": "2026-09-21 10:41:08.518051+00:00"
},
{
"code": "BK0966.DC",
"name": "碳交易",
"db_count": 142,
"site_count": 138,
"intersection_count": 137,
"db_only": [
"000875",
"002734",
"600389",
"601678",
"603612"
],
"site_only": [
"600028"
],
"observed_at": "2026-09-21 10:43:25.392556+00:00"
},
{
"code": "BK1671.DC",
"name": "超跌股",
"db_count": 167,
"site_count": 22,
"intersection_count": 7,
"db_only": [
"000002",
"000010",
"000016",
"000639",
"000677",
"002104",
"002217",
"002227",
"002368",
"002514",
"002542",
"002547",
"002657",
"002691",
"002731",
"002869",
"002891",
"300045",
"300068",
"300100",
"300245",
"300255",
"300290",
"300352",
"300396",
"300430",
"300465",
"300484",
"300492",
"300530",
"300539",
"300584",
"300652",
"300663",
"300682",
"300703",
"300723",
"300779",
"300844",
"300879",
"300896",
"300918",
"300940",
"300995",
"301000",
"301052",
"301076",
"301139",
"301325",
"301498",
"301590",
"301601",
"301622",
"301632",
"600053",
"600180",
"600325",
"600363",
"600418",
"600491",
"600530",
"600702",
"600745",
"601127",
"601865",
"601929",
"603008",
"603189",
"603200",
"603300",
"603359",
"603370",
"603382",
"603392",
"603567",
"603630",
"603718",
"603767",
"603815",
"603848",
"605499",
"688013",
"688066",
"688068",
"688089",
"688121",
"688166",
"688189",
"688201",
"688303",
"688408",
"688496",
"688499",
"688500",
"688567",
"688573",
"688577",
"688588",
"688631",
"688639",
"688648",
"688658",
"688775",
"920001",
"920005",
"920007",
"920056",
"920061",
"920075",
"920090",
"920101",
"920106",
"920108",
"920112",
"920145",
"920146",
"920184",
"920237",
"920239",
"920247",
"920252",
"920263",
"920271",
"920273",
"920274",
"920346",
"920351",
"920375",
"920392",
"920395",
"920414",
"920429",
"920454",
"920469",
"920505",
"920508",
"920522",
"920523",
"920533",
"920578",
"920579",
"920627",
"920634",
"920689",
"920693",
"920719",
"920720",
"920770",
"920781",
"920807",
"920896",
"920906",
"920914",
"920925",
"920926",
"920932",
"920942",
"920982",
"920985",
"920992"
],
"site_only": [
"000004",
"000056",
"000638",
"002630",
"300081",
"300344",
"300561",
"600355",
"600599",
"600696",
"603369",
"605199",
"688287",
"920130",
"920305"
],
"observed_at": "2026-09-21 10:50:20.423741+00:00"
}
]
@@ -0,0 +1,116 @@
{
"rows": 23613,
"dates": 30,
"windows": {
"all_available": {
"rows": 23613,
"dates": 30,
"Ratio_Score": {
"checked_rows": 20449,
"mean_absolute_error": 0.18366711208184938,
"max_absolute_error": 6.680461750893414,
"errors_above_1e-8": 1651
},
"Swing_Score": {
"checked_rows": 16494,
"mean_absolute_error": 0.5097113919412869,
"max_absolute_error": 261.7035740929656,
"errors_above_1e-8": 3199
},
"Amount_Score": {
"checked_rows": 20449,
"mean_absolute_error": 0.1882733317113178,
"max_absolute_error": 7.649258428013809,
"errors_above_1e-8": 1651
},
"Swing_Ratio_Val": {
"checked_rows": 16494,
"mean_absolute_error": 6.7217831480409905e-06,
"max_absolute_error": 0.012044989384502386,
"errors_above_1e-8": 28
},
"Swing_Amount_Val": {
"checked_rows": 16494,
"mean_absolute_error": 0.008108515254276354,
"max_absolute_error": 24.526995094,
"errors_above_1e-8": 28
}
},
"2026-09-04_to_2026-09-21": {
"rows": 9492,
"dates": 12,
"Ratio_Score": {
"checked_rows": 9492,
"mean_absolute_error": 3.2767252412841164e-14,
"max_absolute_error": 3.410605131648481e-13,
"errors_above_1e-8": 0
},
"Swing_Score": {
"checked_rows": 9492,
"mean_absolute_error": 3.441106555949961e-14,
"max_absolute_error": 3.410605131648481e-13,
"errors_above_1e-8": 0
},
"Amount_Score": {
"checked_rows": 9492,
"mean_absolute_error": 3.245420975553957e-14,
"max_absolute_error": 4.547473508864641e-13,
"errors_above_1e-8": 0
},
"Swing_Ratio_Val": {
"checked_rows": 9492,
"mean_absolute_error": 4.942276978457954e-18,
"max_absolute_error": 2.7755575615628914e-17,
"errors_above_1e-8": 0
},
"Swing_Amount_Val": {
"checked_rows": 9492,
"mean_absolute_error": 2.053051893003829e-15,
"max_absolute_error": 5.684341886080802e-14,
"errors_above_1e-8": 0
},
"Ratio_ranking": {
"rank_position_mismatches": 0,
"rank_percentile_max_error": 1.4210854715202004e-14
},
"Swing_ranking": {
"rank_position_mismatches": 0,
"rank_percentile_max_error": 1.4210854715202004e-14
}
},
"2026-09-14_to_2026-09-21": {
"rows": 4746,
"dates": 6,
"Ratio_Score": {
"checked_rows": 4746,
"mean_absolute_error": 3.302167267444722e-14,
"max_absolute_error": 3.410605131648481e-13,
"errors_above_1e-8": 0
},
"Swing_Score": {
"checked_rows": 4746,
"mean_absolute_error": 3.4959814225990485e-14,
"max_absolute_error": 3.410605131648481e-13,
"errors_above_1e-8": 0
},
"Amount_Score": {
"checked_rows": 4746,
"mean_absolute_error": 3.260761983972608e-14,
"max_absolute_error": 4.547473508864641e-13,
"errors_above_1e-8": 0
},
"Swing_Ratio_Val": {
"checked_rows": 4746,
"mean_absolute_error": 4.952800461538844e-18,
"max_absolute_error": 2.7755575615628914e-17,
"errors_above_1e-8": 0
},
"Swing_Amount_Val": {
"checked_rows": 4746,
"mean_absolute_error": 2.056700338399156e-15,
"max_absolute_error": 5.684341886080802e-14,
"errors_above_1e-8": 0
}
}
}
}
@@ -0,0 +1 @@
{"AVAILABLE_DATES":["2026-08-11","2026-08-12","2026-08-13","2026-08-14","2026-08-17","2026-08-18","2026-08-19","2026-08-20","2026-08-21","2026-08-24","2026-08-25","2026-08-26","2026-08-27","2026-08-28","2026-08-31","2026-09-01","2026-09-02","2026-09-03","2026-09-04","2026-09-07","2026-09-08","2026-09-09","2026-09-10","2026-09-11","2026-09-14","2026-09-15","2026-09-16","2026-09-17","2026-09-18","2026-09-21"],"DATA_SOURCE":"","LATEST_DATE":"2026-09-21","files":{"constituents":"radar_data/constituents.25ffae2b8f53.json","dates":{"2026-08-11":"radar_data/dates/2026-08-11.3ed082fdf5aa.json","2026-08-12":"radar_data/dates/2026-08-12.a61e39835037.json","2026-08-13":"radar_data/dates/2026-08-13.db6f36c2dd06.json","2026-08-14":"radar_data/dates/2026-08-14.fb36a77c6ae4.json","2026-08-17":"radar_data/dates/2026-08-17.d69d703faef6.json","2026-08-18":"radar_data/dates/2026-08-18.2c339eebabd6.json","2026-08-19":"radar_data/dates/2026-08-19.fca1cdf24170.json","2026-08-20":"radar_data/dates/2026-08-20.88ff543739cc.json","2026-08-21":"radar_data/dates/2026-08-21.a08c301a8dd7.json","2026-08-24":"radar_data/dates/2026-08-24.9135129011a3.json","2026-08-25":"radar_data/dates/2026-08-25.9393e22d8754.json","2026-08-26":"radar_data/dates/2026-08-26.f49c721846fb.json","2026-08-27":"radar_data/dates/2026-08-27.2dc5f5627eeb.json","2026-08-28":"radar_data/dates/2026-08-28.c92100ea9ae1.json","2026-08-31":"radar_data/dates/2026-08-31.c094ac3098c9.json","2026-09-01":"radar_data/dates/2026-09-01.e6253d4364f6.json","2026-09-02":"radar_data/dates/2026-09-02.1e4a8e6102d5.json","2026-09-03":"radar_data/dates/2026-09-03.ec356ce27a57.json","2026-09-04":"radar_data/dates/2026-09-04.deaf468364a2.json","2026-09-07":"radar_data/dates/2026-09-07.874c2aaa1925.json","2026-09-08":"radar_data/dates/2026-09-08.f93f7b9b2eab.json","2026-09-09":"radar_data/dates/2026-09-09.7a9919c800d9.json","2026-09-10":"radar_data/dates/2026-09-10.29e5d3c66a03.json","2026-09-11":"radar_data/dates/2026-09-11.738e1fec6654.json","2026-09-14":"radar_data/dates/2026-09-14.6b21a73a38f6.json","2026-09-15":"radar_data/dates/2026-09-15.9d5afec9a676.json","2026-09-16":"radar_data/dates/2026-09-16.48d01087bfbd.json","2026-09-17":"radar_data/dates/2026-09-17.04d53b204a8f.json","2026-09-18":"radar_data/dates/2026-09-18.b7a298aedf8d.json","2026-09-21":"radar_data/dates/2026-09-21.22531a461a8b.json"},"rank_history":"radar_data/rank_history.1d389cbfa3e1.json"},"schema_version":1,"version":"7a4fc78a8366"}
@@ -0,0 +1 @@
WITH s AS MATERIALIZED (SELECT DISTINCT ON (api_name,target_trade_date,partition_key) api_name,target_trade_date,partition_key,payload,observed_at FROM sector_radar_source_snapshot WHERE target_trade_date BETWEEN '2026-09-07' AND '2026-09-21' AND api_name IN ('dc_member','daily','moneyflow_dc') ORDER BY api_name,target_trade_date,partition_key,observed_at DESC), d AS (SELECT DISTINCT ON (target_trade_date,e->>'ts_code') target_trade_date,e->>'ts_code' AS code,(e->>'amount')::numeric*1000 AS amount FROM s CROSS JOIN LATERAL jsonb_array_elements(payload) e WHERE api_name='daily' ORDER BY target_trade_date,e->>'ts_code',observed_at DESC), f AS (SELECT DISTINCT ON (target_trade_date,e->>'ts_code') target_trade_date,e->>'ts_code' AS code,(e->>'net_amount')::numeric*10000 AS net FROM s CROSS JOIN LATERAL jsonb_array_elements(payload) e WHERE api_name='moneyflow_dc' ORDER BY target_trade_date,e->>'ts_code',observed_at DESC), m AS (SELECT DISTINCT target_trade_date,e->>'ts_code' AS sector,e->>'con_code' AS code FROM s CROSS JOIN LATERAL jsonb_array_elements(payload) e WHERE api_name='dc_member' AND partition_key<>'all') SELECT m.target_trade_date AS trade_date,m.sector AS sector_code,count(*) AS member_count,count(d.amount) AS daily_count,count(f.net) AS flow_count,sum(d.amount) AS turnover_yuan,sum(f.net) AS net_amount_yuan,sum(d.amount) FILTER (WHERE m.code LIKE '%.BJ') AS bj_turnover_yuan FROM m LEFT JOIN d ON d.target_trade_date=m.target_trade_date AND d.code=m.code LEFT JOIN f ON f.target_trade_date=m.target_trade_date AND f.code=m.code GROUP BY m.target_trade_date,m.sector ORDER BY m.target_trade_date,m.sector
@@ -0,0 +1,97 @@
"""从固定公开输入复算 OneChart 指标;预测阶段不使用网站评分或排名。"""
from __future__ import annotations
import gzip
import json
from pathlib import Path
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parent
def calculate(rows: list[dict]) -> pd.DataFrame:
"""按板块有观测的历史记录计算;预热不足保留 NaN,不填补未知输入。"""
frame = pd.DataFrame(rows).sort_values(["ts_code", "trade_date"])
if frame.duplicated(["trade_date", "type", "ts_code"]).any():
raise ValueError("公开输入存在重复主键")
if frame["Ratio_Raw_Pct"].eq(0).any():
raise ValueError("零流入率无法通过金额/比率反求成交额;需要原始成交额")
frame["turnover_proxy_yuan"] = (
frame["Amount_Raw_BN"] * 1e8 / frame["Ratio_Raw_Pct"]
)
for field in ["Ratio_Raw_Pct", "Amount_Raw_BN", "turnover_proxy_yuan"]:
for window in [3, 5, 10]:
frame[f"{field}_ma{window}"] = frame.groupby("ts_code")[field].transform(
lambda series: series.rolling(window, min_periods=window).mean()
)
# -99 是公开金额/比率所能直接验证的代数修正,不把它当成原始成交额。
frame["liquidity_weight"] = (
np.log10(frame["turnover_proxy_yuan_ma5"] - 99) / 10
)
groups = frame.groupby(["trade_date", "type"])
for field in ["Ratio_Raw_Pct", "Ratio_Raw_Pct_ma3", "Ratio_Raw_Pct_ma10", "Amount_Raw_BN"]:
frame[f"{field}_percentile"] = groups[field].rank(method="average", pct=True)
frame["pred_Ratio_Score"] = (
1000 * frame["Ratio_Raw_Pct_percentile"] * frame["liquidity_weight"]
)
frame["pred_Swing_Score"] = (
1000
* 0.5
* (frame["Ratio_Raw_Pct_ma3_percentile"] + frame["Ratio_Raw_Pct_ma10_percentile"])
* frame["liquidity_weight"]
)
frame["pred_Amount_Score"] = (
1000 * frame["Amount_Raw_BN_percentile"] * frame["liquidity_weight"]
)
frame["pred_Swing_Ratio_Val"] = (
0.5 * (frame["Ratio_Raw_Pct_ma3"] + frame["Ratio_Raw_Pct_ma10"])
)
frame["pred_Swing_Amount_Val"] = (
0.5 * (frame["Amount_Raw_BN_ma3"] + frame["Amount_Raw_BN_ma10"])
)
return frame
def validate(frame: pd.DataFrame) -> dict:
"""比较固定公式的预测与网站输出,报告预热、全历史与稳定覆盖窗口。"""
metrics = ["Ratio_Score", "Swing_Score", "Amount_Score", "Swing_Ratio_Val", "Swing_Amount_Val"]
report = {"rows": len(frame), "dates": frame["trade_date"].nunique(), "windows": {}}
for name, subset in [
("all_available", frame),
("2026-09-04_to_2026-09-21", frame[frame["trade_date"].ge("2026-09-04")]),
("2026-09-14_to_2026-09-21", frame[frame["trade_date"].ge("2026-09-14")]),
]:
result = {"rows": len(subset), "dates": subset["trade_date"].nunique()}
for metric in metrics:
errors = (subset[metric] - subset[f"pred_{metric}"]).abs().dropna()
result[metric] = {
"checked_rows": len(errors), "mean_absolute_error": errors.mean(),
"max_absolute_error": errors.max(), "errors_above_1e-8": int(errors.gt(1e-8).sum()),
}
if name == "2026-09-04_to_2026-09-21":
groups = subset.groupby(["trade_date", "type"])
count = groups["ts_code"].transform("size")
for metric in ["Ratio", "Swing"]:
ranks = groups[f"pred_{metric}_Score"].rank(ascending=False)
percentiles = 100 * (count - ranks + 1) / count
result[f"{metric}_ranking"] = {
"rank_position_mismatches": int(ranks.ne(subset[f"{metric}_RankPos"]).sum()),
"rank_percentile_max_error": percentiles.sub(subset[f"{metric}_RankPct"]).abs().max(),
}
report["windows"][name] = result
return report
if __name__ == "__main__":
fixture = json.loads(gzip.decompress((ROOT / "public-inputs.json.gz").read_bytes()))
calculated = calculate(fixture["rows"])
result = validate(calculated)
(ROOT / "public-validation.json").write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n")
print(json.dumps(result, ensure_ascii=False, indent=2))
@@ -0,0 +1,59 @@
[
{
"index_name":"中药概念 (概念)",
"ts_code":"BK0615.DC",
"type":"概念板块",
"Ratio_Raw_Pct":0.079794960955587,
"Ratio_Raw_Pct_ma3":0.033754406576305,
"Ratio_Raw_Pct_ma10":-0.005866010935792,
"Ratio_Raw_Pct_percentile":0.995169082125604,
"Ratio_Raw_Pct_ma3_percentile":0.929951690821256,
"Ratio_Raw_Pct_ma10_percentile":0.64975845410628,
"turnover_proxy_yuan_ma5":26311461138.200000762939453,
"liquidity_weight":1.042014496452983,
"pred_Ratio_Score":1036.9806099966886,
"Ratio_Score":1036.9806099966886,
"pred_Swing_Score":823.040435604167897,
"Swing_Score":823.040435604167897,
"Ratio_RankPos":2,
"Swing_RankPos":49
},
{
"index_name":"养老概念 (概念)",
"ts_code":"BK0653.DC",
"type":"概念板块",
"Ratio_Raw_Pct":0.065820703429991,
"Ratio_Raw_Pct_ma3":0.037193483577325,
"Ratio_Raw_Pct_ma10":0.006875783666745,
"Ratio_Raw_Pct_percentile":0.985507246376812,
"Ratio_Raw_Pct_ma3_percentile":0.949275362318841,
"Ratio_Raw_Pct_ma10_percentile":0.963768115942029,
"turnover_proxy_yuan_ma5":32135609228.599998474121094,
"liquidity_weight":1.050698653636457,
"pred_Ratio_Score":1035.471136917088188,
"Ratio_Score":1035.471136917088415,
"pred_Swing_Score":1005.016103478350374,
"Swing_Score":1005.016103478350374,
"Ratio_RankPos":3,
"Swing_RankPos":2
},
{
"index_name":"病原体防治 (概念)",
"ts_code":"BK1657.DC",
"type":"概念板块",
"Ratio_Raw_Pct":0.064737379547795,
"Ratio_Raw_Pct_ma3":0.029828519014713,
"Ratio_Raw_Pct_ma10":-0.005451455299003,
"Ratio_Raw_Pct_percentile":0.983091787439614,
"Ratio_Raw_Pct_ma3_percentile":0.908212560386474,
"Ratio_Raw_Pct_ma10_percentile":0.664251207729468,
"turnover_proxy_yuan_ma5":36511340146.0,
"liquidity_weight":1.056242777281107,
"pred_Ratio_Score":1038.383599887464925,
"Ratio_Score":1038.383599887464925,
"pred_Swing_Score":830.451748804348426,
"Swing_Score":830.451748804348426,
"Ratio_RankPos":1,
"Swing_RankPos":47
}
]
@@ -0,0 +1,33 @@
{
"id": "onechart-score-reconstruction",
"name": "onechart-score-reconstruction",
"title": "还原 OneChart 波段与单日资金流评分",
"description": "只读数值还原 OneChart 单日与波段资金评分;12 日 9492 条公开样本精确重现,并完成 PostgreSQL 原始快照对照。",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "yuxuanhui",
"assignee": "yuxuanhui",
"createdAt": "2026-09-21",
"completedAt": "2026-09-21",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "研究完成。没有实施产品修改、修改数据库、共享规格推广或提交。输入版本与成员口径差异详见 research/database-validation.md。",
"meta": {
"work_kind": "read_only_research",
"public_validation_rows": 9492,
"public_score_max_abs_error": 3.410605131648481e-13,
"database_latest_rows": 791,
"database_ratio_mae": 0.9783318452555938,
"database_swing_mae": 1.9075528008117222
}
}
@@ -0,0 +1,9 @@
{"file": ".trellis/spec/backend/index.md", "reason": "后端包边界与必需检查"}
{"file": ".trellis/spec/backend/tushare-listed-stock-universe.md", "reason": "继续遵守当前上市证券母集,不能为对齐网站擅自改变范围"}
{"file": ".trellis/spec/backend/http-api-contracts.md", "reason": "评分与三指标变化字段的端到端契约"}
{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "镜像榜单、控件与可访问性"}
{"file": ".trellis/spec/frontend/type-safety.md", "reason": "API nullable 字段与严格解析"}
{"file": ".trellis/tasks/archive/2026-09/09-21-radar-weighted-score-rank-change/research/formula-evidence.md", "reason": "已验证的评分结构及数值证据边界"}
{"file": ".trellis/tasks/archive/2026-09/09-21-radar-weighted-score-rank-change/research/code-evidence.md", "reason": "现有实现定位与需保持的版本/持久化行为"}
{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "后端 Ruff、Pyright、pytest 和持久化检查"}
{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "前端格式、lint、类型、行为与构建检查"}
@@ -0,0 +1,48 @@
# 设计:加权评分与排名变化
## 计算与领域边界
评分留在 sector_radar 领域/发布阶段,不在 React 中根据当前页数据计算百分位。当前每板块 MetricStrategy 只能得到该板块历史,因此新增全池评分步骤,接收同一目标日所有板块的原始特征,按日期、类型分池计算。
新版本拟为 `zhixing_ratio_weighted_v2` 和 `zhixing_swing_weighted_v2`。设 A 为现有聚合成交额元、F 为聚合主力净额元;按前置研究的可复算输入约定取 `r=F/(A+100)`,A<=0 或输入未知时不制造比率。W=`log10(1+MA5(A))/10`;P 为原始指标升序平均名次/N。
```text
单日原值 = r
单日评分 = 1000 * P(r) * W
波段原值 = .5*MA3(r) + .5*MA10(r)
波段评分 = 1000*(.5*P(MA3(r)) + .5*P(MA10(r)))*W
```
使用 Decimal 计算金额、比率和对数;仅在输出展示时保留 1 位小数。P 的并列值采用平均名次,最终分数并列沿用 sector_code 升序作为稳定破同分规则。原始值与 weighted_score 分离;窗口不足时保留可确定的原始值,评分和对应名次为空,不以原始值替代缺失评分。质量状态和覆盖率继续传播。
新版本使用保存的交易日历确定窗口与对比日期,缺少某个应有交易日输入时保留未知;不得悄悄以更早成功发布替代缺失交易日。第一个可复算日期受库内真实历史覆盖限制。
## 排名、历史与版本
`rank_metric_observations` 对金额继续使用原始金额,对新版本单日/波段使用 weighted_score。上下普通榜筛选使用最终百分位;底榜排序也须与评分键一致。排名变化在同版本最终名次上计算 `past_rank-current_rank`;取 ceil(有效排名池大小×10%),历史不可比者不进入变化候选。
一行排名变化响应提供所选基准兼容字段 rank_change,以及 amount/ratio/swing 三个变化值,供中心主列和两侧辅助列复用。查询选出的前后榜由所选基准确定;点击辅助列只改变该侧当前候选排列。字段均允许 null。
旧发布按其实际 metric_versions 解析定义与历史,不用新版本常量把旧数据过滤成空,也不比较 v1 与 v2 名次。榜单、详情和历史折线共享版本解析与排名事实。首次切换期间旧发布评分显示“—”;历史重算完成后,同日最新成功发布提供新评分。
## 持久化与历史重算
新增 Alembic migration,为 `sector_radar_ranking` 增加 nullable NUMERIC weighted_score,保持 metric_value 原义。同步所有 INSERT、SELECT、序列化和反序列化;内存仓库保持同等语义。
发布构建使用统一评分服务,版本参与 input_hash。增加离线重算入口,仅使用已落库聚合事实和原发布的来源关联,不初始化 Tushare 客户端。按交易日先后创建新派生发布,保留旧发布与原始快照;固定源 publication IDs 避免重算期间新发布改变本轮输入。复用现有按日锁、事务和 last-good 规则,重算幂等键包含源发布身份/输入指纹和新策略版本。
重算时必须复制/关联原聚合的 pct_change、leading_code 及来源组,使新发布的详情仍可读取;不能只写 ranking 而产生空详情。适配器需要提供 publication 精确的 aggregate records 与来源读取,避免现有 history 方法丢失这些元信息。
生产数据库的迁移和重算是独立运行步骤;在本地代码和验证准备完成前不执行外部写入。回退可恢复旧代码版本并重新选择保留的旧发布;nullable 新列本身保持兼容。
## HTTP 与前端
- 在榜单行和必要详情摘要中扩展 weighted_score;排名变化补充三指标变化映射与对比日期信息,同步 Pydantic、TypeScript、解析器和 query 测试。
- 单日/波段左右增加评分列,1 位小数、缺失“—”;其余原始百分比保留独立展示。
- rank_change 使用独立次行,左侧三枚排序基准按钮,右侧近 1–5 日下拉。默认波段率和 1 日,仅在 URL 没有显式值时采用默认。
- 标题为排名飙升榜/排名暴跌榜,中央为“{基准全名}排名变化”;主列显示所选指标变化,辅助列显示另外两项,增加涨跌幅。
- 延续现有主题、表格镜像、板块详情入口和响应式横向滚动;不添加未要求的全局导出、搜索改版或主题重制。
## 主要风险
新算法会改变单日/波段榜及历史名次;必须依靠新版本与离线重算切换,不能给旧名次套新评分。知行的 1000 个板块及按日成员与 OneChart 的 791 板块输入不同,算法结构一致不保证逐值一致。原站并列、缺失与极低成交额策略未完全公开,本系统明确采用上述确定规则。
@@ -0,0 +1,7 @@
{"file": ".trellis/spec/backend/index.md", "reason": "后端包边界与必需检查"}
{"file": ".trellis/spec/backend/tushare-listed-stock-universe.md", "reason": "继续遵守当前上市证券母集,不能为对齐网站擅自改变范围"}
{"file": ".trellis/spec/backend/http-api-contracts.md", "reason": "评分与三指标变化字段的端到端契约"}
{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "镜像榜单、控件与可访问性"}
{"file": ".trellis/spec/frontend/type-safety.md", "reason": "API nullable 字段与严格解析"}
{"file": ".trellis/tasks/archive/2026-09/09-21-radar-weighted-score-rank-change/research/formula-evidence.md", "reason": "已验证的评分结构及数值证据边界"}
{"file": ".trellis/tasks/archive/2026-09/09-21-radar-weighted-score-rank-change/research/code-evidence.md", "reason": "现有实现定位与需保持的版本/持久化行为"}
@@ -0,0 +1,39 @@
# 执行计划
状态:本地实现与范围内验收完成;全仓既有失败已在实施前版本复现。详细结果及生产应用命令见 `validation.md`。
1. 完整阅读要修改的文件;确认已保存的评分研究、接口契约、版本及缺失语义。核对需要使用的 Alembic/Pydantic 等当前版本与官方文档。
2. 实现版本化评分特征和全池计算,分离 raw value/score;更换单日/波段排序键,补独立数值样例、并列、未来数据排除及缺失窗口测试。
3. 新增 nullable score 列迁移;同步 PostgreSQL/内存仓库所有读写与 historical ranking 查询。补充真实数据库往返、旧行 NULL 和事务失败测试。
4. 接入发布构建与 input_hash;实现不访问 Tushare 的历史重算入口、新旧版本解析及严格对比日期。验证幂等、旧发布保留、失败 last-good 与详情来源完整。
5. 扩展榜单/详情 HTTP 输出和前端类型解析,支持评分、三指标变化值及对比日期;覆盖不匹配版本与缺失历史。
6. 单日/波段表格添加评分列与侧内排序;按截图调整排名变化次级控制行、双榜标题、中央标题、涨跌幅和两项辅助变化列,保持 URL/查询联动。
7. 执行领域、发布、读取、HTTP、仓库、前端 API 和页面行为测试;用本地测试数据库验证迁移和离线重算,避免使用用户生产库进行测试写入。
8. 完成后端 Ruff/Pyright/pytest 与前端格式、lint、typecheck、Vitest/build。浏览器实际检查三基准、1–5 日、空历史、评分列和窄屏;保存必要截图和验证结果。
9. 检查 diff 范围、已有用户改动和未验证事项;交付本地成果及明确的迁移/重算命令,不自行提交、部署或写入生产库。
## 验证命令
```bash
cd zhixing-server
uv run ruff format --check .
uv run ruff check .
uv run pyright
uv run pytest
```
```bash
cd zhixing-web
pnpm check
pnpm build
```
实现期间先执行相关测试,最后执行项目规定完整检查;通过后仅在新改动或未解决问题需要时重复。
## 高风险核验点
- P 必须来自完整同类池,不能来自当前页面 TOP10% 子集。
- Swing 分数是两次排名后合成,不是合成 raw 后再排名。
- 历史不足时不能伪造评分/0变化;不能跨失败交易日跳位或跨版本相减。
- migration 列顺序涉及所有 ranking SELECT 与 fake row,不得只改写入。
- 离线重算的新发布必须可继续打开详情,且不得重新请求上游或改变旧快照。
@@ -0,0 +1,46 @@
# 雷达加权评分与排名变化复刻
## Goal
根据已验证的 OneChart 评分公式,为单日和波段榜增加加权评分,并复刻排名变化的排序基准、1至5日窗口和双榜交互。
## Requirements
- R1:单日流入率与波段流入率双榜各增加“加权评分”列,左右镜像排列,保留 1 位小数,支持表头排序;原始流入率仍单独展示。
- R2:采用本轮前置研究验证的评分结构。单日和波段榜按各自加权评分形成最终排名;波段原始流入率采用 3 日、10 日单日流入率均值各 50%。单日净额榜按净额形成排名。
- R3:排名变化视图按参考截图提供独立次级控制行:左侧“排序基准”含波段率、单日率、单日额,右侧“统计天数”含近 1–5 日;首次进入默认波段率、近 1 日,并保留 URL 中显式指定的选择。
- R4:排名变化视图展示“排名飙升榜 TOP 10% / 排名暴跌榜 BOTTOM 10%”,中央标题随基准切换;每行显示涨跌幅、所选基准的名次变化以及其余两个指标的名次变化。上下榜由所选基准决定,表头排序只重排该榜现有候选。
- R5:名次变化为过去名次减当前名次,统计基于交易日,概念与行业分池。历史缺失、算法版本不兼容时显示“—”,不得伪造 0 或混用新旧算法名次。
- R6:评分、最终名次、排名变化和详情历史使用相同算法版本;提供基于已有聚合事实重算历史的能力,旧发布保持可追溯。已有旧版本数据升级前仍能安全读取。
- R7:继续使用知行当前数据库、板块池、按日成员快照与当前上市股票范围。复刻评分结构与交互,不以抓取 OneChart 结果替代业务计算,也不承诺与不同输入口径的网站逐值相等。
## 范围边界
- 本任务包含必要的领域计算、数据库派生字段迁移、HTTP 契约、前端及测试,以及历史重算命令。
- 不包含调整证券母集、将网站当前成员回填历史、额外的榜单导出或搜索功能重写。
- 本地实现与非破坏性验证按任务执行;生产迁移、生产历史重算、部署不在本轮执行范围内;Git 提交与推送已由后续用户请求明确授权。
## Acceptance Criteria
- [x] AC1/R1:单日/波段榜显示左右“加权评分”列,有限值为 1 位小数,缺失为“—”,排序与表头状态正确。
- [x] AC2/R2:独立样例验证百分位、5 日成交额权重、3/10 日各 50% 及“先分别排名再合成”;高原始比率但低评分的反例能按评分正确排名。
- [x] AC3/R3–R4:三基准 × 五窗口切换正确更新双榜、中央标题和辅助两列,URL 刷新可恢复;正负方向、镜像布局和窄屏横向滚动正确。
- [x] AC4/R5:交易日跨周末、缺发布、缺板块、零变化、并列及新旧版本不匹配均有测试,榜单选取为对应池 ceil(N×10%) 个有效变化候选的上限。
- [x] AC5/R6:旧行 weighted_score 为空时可读取;历史重算创建可追溯新发布且幂等,不请求外部数据;失败不影响最近成功发布,详情与榜单的当前名次一致。
- [ ] AC6/R7:沿用当前股票/板块数据范围;单元、HTTP、前端、持久化及浏览器验证覆盖变更,项目规定的质量检查通过。
## 已确认的现状与依据
- 前置研究:`research/formula-evidence.md`。公开输入 9,492 条样本的评分与最终名次可精确重现;数据库输入存在成员/板块池差异。
- 当前单日/波段只有原始 metric_value;旧波段策略实际为 3 至 10 日八个净额/成交额窗口等权,并非新研究中的两个窗口,见 `domain/metrics.py:140` 起。
- 当前排名变化已有基础 API 与 1–5 日变化值,但 UI 控件、标题、辅助列不匹配截图,且历史名次基于旧原始指标,定位见 `research/code-evidence.md`。
## 决策状态
用户已明确回复“同意,开始实施”,批准本版方案。执行现有数据口径下的评分和交互升级;按个人约定由主代理编码与最终验证,子代理仅承担只读探索和独立核验。
## 本轮验收状态
AC1–AC5 已完成。AC6 的变更范围检查和浏览器验收通过;全仓检查仍有在实施前 HEAD 上独立复现的既有失败,因此保留未完全通过状态。详见 `validation.md`。本地实现已交付;功能代码已提交为 `b9981aa`,生产迁移、重算和部署未执行。
用户已于本轮授权提交并推送。本任务的实现和范围内验证完成;AC6 的全仓基线失败仍明确保留为已知限制,不扩展修复选股/行情模块。线上重算说明已补入 `docs/market-data-sync.md`。
@@ -0,0 +1,36 @@
# 现有实现与改动定位
只读研究已完成:`/root/radar_storage_research`、`/root/radar_ui_research`,主会话另行阅读领域算法、models、read、build 和 HTTP 相关契约。
## 领域与应用
- `domain/metrics.py:140` 起,旧 SwingEqualThreeToTenStrategy 取 3–10 日八个窗口的累计净额/累计成交额,再等权平均;需采用新版本,不能把它误认作前置研究的两个窗口。
- `domain/ranking.py:42` 的排序键按 observation.value 排序;`with_rank_changes` 同版本过去名次减当前名次;select_rank_change_side 已实现 ceil(N×10%),不需另写重复算法。
- `application/build.py:694` 的 _rank 读取 9 个历史成功日期再拼当前日;历史排名取前 5 次成功发布,并非严格前 5 交易日。
- `application/build.py:753` 的 input_hash 包含策略版本;重算可沿用版本隔离与幂等思路。
- `application/read.py` 的 _METRIC_DEFINITIONS 和 `application/details.py:25` 的 _METRIC_VERSIONS 都硬编码当前策略;升级必须让旧发布保持可读。
- `application/read.py:289` 的 extras 只服务 amount/ratio,swing/rank_change 的涨跌幅、净额和辅助字段需要补齐。
- `application/details.py:173` 的 history_data 已按发布来源中的交易日历补出缺口,适合统一严格交易日语义;必须避免每行单独查库。
## 存储
- `domain/persistence.py:294–355` 定义 aggregate、ranking、history 读写契约。
- `infrastructure/postgres.py:532–710` 为事务发布,`:741–778` 为 ranking 批写,`:1145–1173` 为序列化,`:1239–1267` 为固定列反序列化。
- ranking SELECT 位于 `postgres.py:871–964`、`:996–1033`,当前只含 metric_value,没有 score。
- `postgres.py:966–994` 的 aggregate history 只返回原始金额/coverage,不包含 publication_id、pct_change、leading_code,离线重算需要精确来源记录。
- 最新迁移为 `0009_radar_sector_detail`。新增 score 应采用独立 migration,旧值可空。
- 单测 `tests/unit/sector_radar/test_postgres_repository.py:43–65` 使用 17 列 fake row,必须随查询同步。现有集成测试尚未覆盖 ranking/aggregate 往返。
## 前端
- `pages/sector-radar-page.tsx:209–229` 已有两个下拉,分别为变化指标/对比区间;改为截图的按钮组与统计天数次行。
- `pages/sector-radar-page.tsx:406–428` 顶部双榜和中央标题仍是普通资金榜文案。
- `pages/sector-radar-page.tsx:430–463`、`:643–708` 是单日/波段镜像列,无 score。
- `pages/sector-radar-page.tsx:465–500` 的变化榜目前只显示样本、排名百分位与一个变化值;应展示涨跌幅及另外两项指标变化。
- `api/sector-radar.types.ts:61–114` 与 `api/sector-radar.api.ts:277–377` 仅承载单个 rank_change,需扩展。
- 路由与 query key 已保存基准/天数,保留这一结构;URL 显式值优先。
- 页面测试 `sector-radar-page.test.tsx:654–683` 已覆盖正变化/缺历史,需增加评分、三基准五窗口、镜像辅助列、侧内排序与截图文案。
## 参考站核对
本会话下载的 `/tmp/onechart-public-evidence/index.html` 中:`:1197` 默认 Swing/1 日,`:1380` 附近 mirrorMetricLabel 随基准变,`:1397–1427` 的 mirrorAuxColumns 在变化榜显示另外两个基准,在单日/波段榜显示 score/净额/在榜。前置研究完整来源和数值证据位于已归档的评分研究任务。
@@ -0,0 +1,19 @@
# 加权公式与验证边界
前置研究在 2026-09-04 至 2026-09-21 的 9,492 条公开样本上复现单日、波段评分与最终排名;分数最大误差 3.41e-13,属于浮点运算误差。这是观测数据还原,不是原站源码证据。
对本项目已有聚合数据,F 为主力净额元、A 为成交额元:
```text
r = F / (A + 100)
W = log10(1 + MA5(A)) / 10
单日评分 = 1000 × P(r) × W
波段评分 = 500 × (P(MA3(r)) + P(MA10(r))) × W
波段原值 = (MA3(r) + MA10(r)) / 2
```
P 为同日、同类型全池升序平均名次 / N。波段必须先分别排名,再合成。最终评分并列按板块代码稳定排序。权重可能大于 1,分数不是固定上限 1000 的百分制。
公开字段可以确认的等价权重是 `log10(MA5(F/r)-99)/10`;`A=F/r-100` 是本任务采用的可复算口径,不能证明原站源码中具体常量的用途。原站边界与并列规则未完全公开,本地明确使用 design.md 中的完整交易日窗口、平均并列名次及缺失语义。
公开验证池为 791 个板块,本地数据库池和按日成员不同。本项目保留当前上市股票与已保存历史输入,因此不承诺与网站逐值相同。原始研究数据和复算快照保留在本地前置研究任务中,不是运行本功能的依赖。
@@ -0,0 +1,166 @@
"""Synthetic browser fixtures; restricted to the disposable local radar test DB."""
import hashlib
import math
import os
from dataclasses import replace
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
from urllib.parse import urlsplit
import psycopg
from zhixing_server.modules.sector_radar.application.scoring import calculate_rankings
from zhixing_server.modules.sector_radar.domain.metrics import (
AmountNetStrategy,
RatioTurnoverStrategy,
SwingEqualThreeToTenStrategy,
)
from zhixing_server.modules.sector_radar.domain.models import (
PublicationStatus,
RadarPublication,
SectorDailyAggregate,
SectorType,
)
from zhixing_server.modules.sector_radar.domain.persistence import (
DailyAggregateRecord,
PublicationSourceGroup,
PublicationSourceRecord,
RankingRecord,
)
from zhixing_server.modules.sector_radar.domain.source import build_source_snapshot
from zhixing_server.modules.sector_radar.infrastructure.postgres import (
PostgresSectorRadarRepository,
)
url = os.environ["ZHIXING_TEST_DATABASE_URL"]
location = urlsplit(url)
if (location.hostname, location.port, location.path) != ("127.0.0.1", 55439, "/radar_test"):
raise SystemExit("Preview seeding is restricted to the disposable local radar test database")
with psycopg.connect(url) as connection:
connection.execute("TRUNCATE sector_radar_publication CASCADE")
start = date(2026, 8, 10)
days = tuple(
start + timedelta(days=n) for n in range(43) if (start + timedelta(days=n)).weekday() < 5
)
strategies = (AmountNetStrategy(), RatioTurnoverStrategy(), SwingEqualThreeToTenStrategy())
names = (
"人工智能",
"机器人",
"商业航天",
"低空经济",
"半导体",
"创新药",
"新能源",
"电力设备",
"数字经济",
"消费电子",
)
now = datetime.now(UTC)
calendar = build_source_snapshot(
api_name="trade_cal",
params={"fixture": "weighted-radar-preview"},
rows=[{"exchange": "SSE", "cal_date": day, "is_open": 1} for day in days],
target_trade_date=days[-1],
observed_at=now,
)
repository = PostgresSectorRadarRepository(url)
history = []
try:
repository.save_source_snapshots((calendar,))
for index, day in enumerate(days):
aggregates = []
for sector_type, size in ((SectorType.CONCEPT, 300), (SectorType.INDUSTRY, 100)):
for n in range(size):
turnover = Decimal(
str(10 ** (8 + n % 4) * (1 + 0.2 * math.cos(n + index)))
).quantize(Decimal(".01"))
ratio = Decimal(
str(
0.09 * math.sin(n * 1.37 + index * 0.43) + 0.02 * math.cos(n * 0.71 + index)
)
)
aggregates.append(
SectorDailyAggregate(
day,
sector_type,
f"TEST-{sector_type.value[0]}-{n:04}",
f"{names[n % len(names)]} {n + 1}",
10,
10,
(turnover * ratio).quantize(Decimal(".01")),
turnover,
Decimal(1),
Decimal(1),
)
)
publication = RadarPublication(
f"preview-{day}",
day,
PublicationStatus.RUNNING,
"local-preview-v1",
"synthetic-preview-v1",
tuple(strategy.metric_version for strategy in strategies),
None,
Decimal(1),
now,
)
repository.create_publication(publication)
repository.save_publication_sources(
(
PublicationSourceRecord(
publication.publication_id, PublicationSourceGroup.CALENDAR, 0, calendar
),
)
)
for sector_type, group in (
(SectorType.CONCEPT, PublicationSourceGroup.CONCEPT_INDICES),
(SectorType.INDUSTRY, PublicationSourceGroup.INDUSTRY_INDICES),
):
snapshot = build_source_snapshot(
api_name="dc_index",
params={"fixture": "preview", "type": sector_type.value},
rows=[
{
"trade_date": day,
"ts_code": row.sector_code,
"name": row.sector_name,
"pct_change": Decimal(str(3 * math.sin(n + index))).quantize(
Decimal(".01")
),
}
for n, row in enumerate(aggregates)
if row.sector_type is sector_type
],
target_trade_date=day,
observed_at=now,
)
repository.save_source_snapshots((snapshot,))
repository.save_publication_sources(
(PublicationSourceRecord(publication.publication_id, group, 0, snapshot),)
)
ranks = calculate_rankings(day, aggregates, history, days, strategies)
repository.finalize_publication(
replace(
publication,
status=PublicationStatus.SUCCESS,
input_hash=hashlib.sha256(str(day).encode()).hexdigest(),
finished_at=now,
),
memberships=(),
stock_facts=(),
daily_aggregates=(
DailyAggregateRecord(
publication.publication_id,
row,
Decimal(str(3 * math.sin(n + index))).quantize(Decimal(".01")),
)
for n, row in enumerate(aggregates)
),
rankings=(RankingRecord(publication.publication_id, row) for row in ranks),
)
history.extend(aggregates)
print(f"Seeded {len(days)} days × 400 synthetic sectors; no production data or provider calls.")
finally:
repository.close()
@@ -0,0 +1,41 @@
{
"id": "radar-weighted-score-rank-change",
"name": "radar-weighted-score-rank-change",
"title": "雷达加权评分与排名变化复刻",
"description": "根据已验证的 OneChart 评分公式,为单日和波段榜增加加权评分,并复刻排名变化的排序基准、1至5日窗口和双榜交互。",
"status": "completed",
"dev_type": "fullstack",
"scope": "sector-radar",
"package": null,
"priority": "P2",
"creator": "yuxuanhui",
"assignee": "yuxuanhui",
"createdAt": "2026-09-21",
"completedAt": "2026-09-21",
"branch": "develop",
"base_branch": "develop",
"worktree_path": null,
"commit": "b9981aa48de32dc1fac03f2febfbdd6786ff0f72",
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [
"zhixing-server/src/zhixing_server/modules/sector_radar/domain/weighted.py",
"zhixing-server/src/zhixing_server/modules/sector_radar/application/recompute.py",
"zhixing-server/migrations/versions/0010_radar_weighted_score.py",
"zhixing-web/src/features/sector-radar/pages/sector-radar-page.tsx",
"zhixing-web/src/features/sector-radar/components/radar-table-sort.ts"
],
"notes": "功能代码提交 b9981aa;后端雷达 110 项、前端雷达 74 项及浏览器 30 组合通过。独立检查未发现新增阻塞。全仓既有失败见 validation.md;原始研究快照未纳入提交,生产迁移、重算、部署未执行。",
"meta": {
"planning_status": "approved",
"planning_summary_version": 1,
"production_writes_authorized": false,
"implementation_status": "complete",
"validation_status": "scoped_pass_with_verified_baseline_failures",
"commit_push_authorized": true,
"spec_sync": "No shared spec promotion; reviewed formula and limitations retained in task artifacts.",
"accepted_baseline_limitations": true
}
}
@@ -0,0 +1,71 @@
# 实施与验证记录
2026-09-21,本地实施完成。功能代码已提交为 `b9981aa`;本轮用户已授权推送。生产数据库未迁移、未重算,未部署。
## 已实现
- 单日/波段流入率增加镜像加权评分列,保留 1 位小数,原始率独立展示。左右列头分别排序,加载该侧完整候选榜后应用排序,NULL 始终置后。
- 新算法版本为 `zhixing_ratio_weighted_v2`、`zhixing_swing_weighted_v2`。全池平均名次百分位与 5 日成交额权重在后端计算,波段使用 MA3/MA10 分别排名后各占 50%。
- 排名变化提供波段率/单日率/单日额、近 1–5 天、涨跌幅、另外两项指标的变化、镜像双榜和随基准更新的标题。URL 无显式值时默认波段率、近 1 天。
- 对比使用保存的交易日历和同版本名次。缺发布、缺板块、历史不足或版本不匹配时保持未知,不跳过缺失交易日、不伪造零。
- 迁移 `0010_radar_weighted_score` 增加 nullable NUMERIC(28,12) 和有限值约束。全部排名读写路径、HTTP、前端解析与详情历史同步扩展。
- `sector-radar-build --recompute` 只读取已有聚合事实与源快照,按日期生成新发布,保留原发布。当前名次、历史和详情使用相同版本。
## 实际验证
| 检查 | 结果 |
| --- | --- |
| 后端雷达领域、构建、读取、HTTP、PostgreSQL 集成 | 110 passed |
| 前端雷达 API、query、详情、页面 | 74 passed |
| 后端 Ruff format/check | 通过 |
| 本次后端模块、测试和迁移 Pyright | 0 errors |
| 前端格式、ESLint、TypeScript 和生产 build | 通过;Vite 仍提示既有大 chunk |
| migration 0010 → 0009 → 0010 | 288 条原始排名保留,新增评分为 NULL |
| 真实 PostgreSQL 重算 | 保留旧发布、评分在各读取路径一致、详情来源完整、失败不替换 last-good |
| CLI 完整重算 | 31 日 × 400 个合成板块;首次 success,重复 31 日均 unchanged |
| 浏览器 2 类型 × 3 基准 × 5 天数 | 30 组合通过;首行变化与 API 一致,刷新恢复 URL 选择 |
| 浏览器评分排序 | 左榜完整 31 个、右榜完整 30 个候选;升降序正确且两侧独立 |
| 浏览器窄屏与缺历史 | 390px 页面无整页横向溢出;960px 表格可横向滚动;空历史文案完整可见 |
| Git diff/context manifests | diff --check 与 7/9 条上下文清单校验通过 |
数据库验证均在单独创建的本地 PostgreSQL 17 容器执行,未连接生产数据库。浏览器预览为合成测试数据;`research/seed_local_preview.py` 限制只能写入本地测试库。控制台仅观察到既有 favicon.ico 404,没有本次页面异常。
## 全仓基线问题
完整命令已执行,但不能报告全仓检查通过。已用 `git archive HEAD`(实施前 HEAD `669e89d`)及相同依赖独立复现:
- 后端全量 Pyright:14 个相同错误,位于 selection 的 `chart.py`、`gold_brick.py`、`test_run.py`。
- 后端全量 pytest:本次 230 passed / 6 failed,修改前 221 passed / 同样 6 failed。既有 market_data 集成测试调用不存在的 Connection.executemany;旧迁移测试日志配置导致后续 5 个日志断言失败。雷达相关 110 项在独立运行时全部通过。
- 前端 `pnpm check` 的格式、lint 和类型均通过;全量 Vitest 为 148 passed / 4 failed。修改前 selection 页面同样 4 个执行状态弹窗测试失败(其余 20 个通过)。
这些既有选股/行情问题未在本任务扩展修复。前端 `.prettierignore` 补充已被 Git 忽略的 `.playwright-cli`,避免旧浏览器快照影响格式检查。
## 生产应用步骤(尚未执行)
生产 Compose 的完整构建、迁移、重算顺序见 `docs/market-data-sync.md`。以下为直接使用 Python 环境时的等价命令。
在目标环境确认 `ZHIXING_DATABASE_URL` 指向正确数据库,先执行迁移,再上线代码并重算现有日期区间:
```bash
cd zhixing-server
uv run alembic upgrade head
uv run sector-radar-build --recompute --start-date 2026-08-28 --end-date 2026-09-21
```
日期应覆盖需要展示的已有历史;命令只处理区间内已有成功发布。单日评分需要连续 5 个交易日成交额,波段评分需要连续 10 个交易日输入;要比较近 5 日波段名次,还需要相应更早窗口。缺失日期保持未知,重算不补造原始行情或成员数据。
新列允许旧行为 NULL,旧发布保留追溯。回退需切回保留的旧版本发布;不能在仍运行新代码时直接删列。当前代码库没有自动生产回退动作,本任务没有执行任何生产状态变更。
## 预览证据
- `output/playwright/radar-rank-change-desktop.png`
- `output/playwright/radar-swing-desktop.png`
- `output/playwright/radar-ratio-desktop.png`
- `output/playwright/radar-rank-change-mobile.png`
- `output/playwright/radar-missing-history-mobile.png`
本地预览:`http://127.0.0.1:15173/sector-radar?view=rank_change&tradeDate=2026-09-21`。前端端口 15173,后端端口 18081,数据库容器 `zhixing-radar-score-test`(本地端口 55439);均用于隔离验收。
## 提交前复核
本轮独立只读检查由 `/root/radar_commit_review` 完成,未发现新增正确性或部署阻塞;范围内 Ruff/Pyright 和 diff 检查通过。Compose Job 的 entrypoint 与新版 CLI 参数已核对,并使用无生产凭据的配置通过 `docker compose config --quiet`。线上操作说明已补入 `docs/market-data-sync.md`;本轮只执行 Git 提交与推送,不执行线上迁移、重算或部署。
+6 -4
View File
@@ -8,8 +8,8 @@
<!-- @@@auto:current-status --> <!-- @@@auto:current-status -->
- **Active File**: `journal-1.md` - **Active File**: `journal-1.md`
- **Total Sessions**: 15 - **Total Sessions**: 17
- **Last Active**: 2026-09-01 - **Last Active**: 2026-09-25
<!-- @@@/auto:current-status --> <!-- @@@/auto:current-status -->
--- ---
@@ -19,7 +19,7 @@
<!-- @@@auto:active-documents --> <!-- @@@auto:active-documents -->
| File | Lines | Status | | File | Lines | Status |
|------|-------|--------| |------|-------|--------|
| `journal-1.md` | ~356 | Active | | `journal-1.md` | ~420 | Active |
<!-- @@@/auto:active-documents --> <!-- @@@/auto:active-documents -->
--- ---
@@ -29,6 +29,8 @@
<!-- @@@auto:session-history --> <!-- @@@auto:session-history -->
| # | Date | Title | Commits | Branch | | # | Date | Title | Commits | Branch |
|---|------|-------|---------|--------| |---|------|-------|---------|--------|
| 17 | 2026-09-25 | 部署运行器调整与全部存量任务收尾 | `f020362` | `develop` |
| 16 | 2026-09-21 | 雷达加权评分与排名变化上线准备 | `b9981aa` | `develop` |
| 15 | 2026-09-01 | 选股模块技术图表迭代 | `9145fe4`, `feb8fd4` | `-` | | 15 | 2026-09-01 | 选股模块技术图表迭代 | `9145fe4`, `feb8fd4` | `-` |
| 14 | 2026-09-01 | 完成板块资金雷达双榜交互对齐 | `744cb76` | `develop` | | 14 | 2026-09-01 | 完成板块资金雷达双榜交互对齐 | `744cb76` | `develop` |
| 13 | 2026-09-01 | 板块资金雷达滚动加载迭代 | `be31235`, `5f34be5` | `develop` | | 13 | 2026-09-01 | 板块资金雷达滚动加载迭代 | `be31235`, `5f34be5` | `develop` |
@@ -52,4 +54,4 @@
- Sessions are appended to journal files - Sessions are appended to journal files
- New journal file created when current exceeds 2000 lines - New journal file created when current exceeds 2000 lines
- Use `add_session.py` to record sessions - Use `add_session.py` to record sessions
+54
View File
@@ -364,3 +364,57 @@
### Status ### Status
[OK] **Completed** [OK] **Completed**
## Session 16: 雷达加权评分与排名变化上线准备
**Date**: 2026-09-21
**Task**: 雷达加权评分与排名变化上线准备
**Branch**: `develop`
### Summary
实现单日/波段加权评分、三基准与1至5交易日排名变化、版本兼容与离线历史重算。雷达后端110项、前端74项和浏览器30组合通过;全仓既有失败已在修改前HEAD复现,独立复核无新增阻塞。补充生产Compose迁移及重算说明。用户授权提交和推送,生产数据库未改动;前置研究原始快照留本地。
### Git Commits
| Hash | Message |
|------|---------|
| `b9981aa` | (see git log) |
### Status
[OK] **Completed**
## Session 17: 部署运行器调整与全部存量任务收尾
**Date**: 2026-09-25
**Task**: 部署运行器调整与全部存量任务收尾
**Branch**: `develop`
### Summary
生产部署运行器改为 tencent-prod,本地 origin 更新至 my-home/zhixing-system;按用户明确要求将全部 5 个未归档任务标记 completed 并归档,清理空 Redis 任务目录及残留会话引用。
### Main Changes
- 归档:09-01-optimize-stock-list-radar、09-04-add-gold-brick-strategy、09-05-selection-layout-sector-filter、09-06-capital-radar-daily-detail、09-07-api-performance-diagnosis。
- 本次为用户要求的统一完结;保留原始 PRD、验收勾选、验证记录和历史限制,不将归档等同于全部功能重新验收通过。
- 两份 .zcode/plans/ 文件的既有删除未修改、未纳入提交;未推送远端或执行部署。
### Git Commits
| Hash | Message |
|------|---------|
| `f020362` | (see git log) |
### Testing
- [OK] 部署配置:YAML 解析、main 触发与 tencent-prod 标签断言、生产 Compose 默认及 jobs 配置校验、git diff --check 均通过。
- [OK] 归档核验:5 个任务元数据仅修改 status/completedAt;其余任务文件哈希与归档前一致;全部活动任务清空,残留当前任务引用已清除。
- [OK] 本轮未重跑产品测试或验证历史任务的线上验收;已有验证限制保留在归档文档中。
### Status
[OK] **Completed**
@@ -1,35 +0,0 @@
## 调研结论(可行性:具备)
**现状**
- 详情面板 `zhixing-web/src/features/selection/components/signal-detail-panel.tsx` 只展示名称/代码/价格/指标,无行业板块;`SelectionStockResult` 类型也无相关字段。
- 后端 `market_stock` 表没同步 tushare `stock_basic.industry`;但项目已有 **sector_radar 模块**,通过 tushare `dc_index`/`dc_member` 每日同步"行业板块 + 概念板块"的成分股快照到 PostgreSQL 表 `sector_radar_membership`(trade_date + sector_type=industry/concept + sector_code/name + stock_code),只是目前只暴露了板块级 rankings 端点,**没有"按股票反查所属板块"的接口**——这就是缺口的全部。
- 已确认决策:数据口径复用 sector_radar(DC 行业+概念);仅详情面板展示,列表行不动。
## 实施方案
### 第 0 步:数据验证(前置)
启动 docker compose 的 PostgreSQL,检查 `sector_radar_membership` 按交易日的覆盖情况。若选股目标日期无快照,运行现有 CLI `sector-radar-build` 回补。若 dc_member 接口因 tushare 积分不足失败,回退方案是给 `market_stock` 加 `stock_basic.industry` 列(Alembic 迁移),届时向你说明。
### 后端(sector_radar 模块内新增"按股票反查",不跨模块读表)
1. `modules/sector_radar/infrastructure/postgres.py`:新增读查询——按 `stock_code` 查 `membership_status='available'` 且 `trade_date <= 目标日` 的最近可用快照(带出实际 membership 的 trade_date,point-in-time 语义,不用最新日期冒充历史)。
2. `modules/sector_radar/application/read.py`:新增用例,输入 `ts_code + trade_date`,返回按类型分组的板块列表(industry 全部、concept 排序后默认截断 20 个)。
3. `modules/sector_radar/presentation/http.py`:新增端点
`GET /api/v1/sector-radar/stocks/{ts_code}/membership?trade_date=YYYY-MM-DD&limit=20`
响应:`{ ts_code, trade_date, membership_trade_date, industries: [{code,name}], concepts: [{code,name}], concept_total }`,遵循 http-api-contracts spec(Pydantic 边界模型)。
### 前端(selection feature)
4. 新增 API 调用 + TanStack Query hook(`enabled: !!stock`,按 `stock.target_trade_date` 查询,随详情面板选中切换)。
5. `signal-detail-panel.tsx`:股票名/代码/价格下方新增两行展示——
- **行业**:行业标签(通常 1 个)
- **所属板块**:概念板块 chips,最多显示 8 个 + "等 N 个"
加载中显示占位文案,无数据时静默隐藏(不打扰无板块数据的股票)。
### 测试与验证
- 后端:读用例单测(命中/无快照/截断)+ http 端点测试,参照 sector_radar 现有测试。
- 前端:vitest 组件测试 + typecheck/lint。
- 端到端:起 server + web,在 `/selection` 选一只股票确认行业与板块正确显示、无数据股票正常降级。
### 涉及文件
- `zhixing-server/.../modules/sector_radar/{application/read.py, infrastructure/postgres.py, presentation/http.py}`
- `zhixing-web/src/features/selection/api/*`(新 query)
- `zhixing-web/src/features/selection/components/signal-detail-panel.tsx`
@@ -1,42 +0,0 @@
# 选股页面迭代:上搜索 + 左列表/右详情布局 & 板块筛选
## 现状与关键结论
- 布局:`selection-results-workbench.tsx` 目前是左右两栏 grid(左 320px 筛选+列表 / 右详情),筛选栏嵌在左栏顶部。
- 板块数据:选股结果表(`selection_run_item`)没有板块字段;板块归属在 `sector_radar_membership` 表,现有 membership 接口只支持单股查询。**板块聚合必须由后端新增**(前端逐股请求既慢又只覆盖已加载分页,计数不准)。
- 已确认口径(用户未答,按推荐执行):概念板块(`sector_type='concept'`,与详情面板"板块"标签一致),单选;接口保留 `sector_type` 参数,后续扩展行业零成本。排序 = 按 `stock_count` 倒序(选项旁展示数量)。
## 一、后端(zhixing-server)
遵循 bounded-context-first(ADR 0001):selection 不直接 join sector_radar 表,通过应用层端口调用 sector_radar 的读服务。
1. **sector_radar 模块**(`application/read.py` + `infrastructure/postgres.py`):
- 仓储新增两个只读方法(复用已有的 `get_last_good_publication` 解析快照日):
- `load_sector_counts(stock_codes, snapshot_date, sector_type)`:`SELECT sector_code, sector_name, COUNT(*) FROM sector_radar_membership WHERE stock_code = ANY(%s) AND trade_date = %s AND sector_type = %s AND membership_status='available' GROUP BY 1,2`
- `load_sector_member_codes(snapshot_date, sector_code, sector_type)`:返回该板块成员股票代码列表
- 读服务 `ReadSectorRadar` 新增:`sector_counts(stock_codes, trade_date, sector_type)`(内部解析 last-good publication,返回按 stock_count 降序、名称升序)和 `sector_member_codes(trade_date, sector_code, sector_type)`。
2. **selection 模块**:
- `domain/runs.py`:`SelectionResultQuery` 增加 `sector: str | None`;新增 `SelectionSectorCount(sector_code, sector_name, stock_count)` 值对象;定义端口协议 `SectorMembershipReader`(`sector_counts` / `sector_member_codes` 两个方法)。
- `application/run.py`:新增 `list_sector_counts(strategy, target_trade_date, sector_type)` —— 取 latest run,收集 `status='selected' AND signal_count>0` 的 ts_code,调端口聚合;`get_latest`/`get` 结果查询在 `query.sector` 有值时先调端口取成员代码,空则直接返回空页,否则把代码数组传入仓储。
- `infrastructure/postgres_runs.py`:`_stock_filter` 增加子句 `item.ts_code = ANY(%s)`(参数由应用层传入)。
- `presentation/http.py`:
- 新端点 `GET /api/v1/selection/sectors?strategy=&target_trade_date=&sector_type=` → `{ sector_type, snapshot_trade_date, sectors: [{sector_code, sector_name, stock_count}] }`
- `GET /results` 与 `GET /runs/{id}` 增加 `sector` query 参数校验(去空格、限长)。
- 组合根(router/依赖装配处)把 sector_radar 的读服务适配为 selection 的端口注入。
3. **测试**:`tests/unit/sector_radar/`(新仓储方法)、`tests/unit/selection/test_postgres_runs.py`(sector 过滤、ANY 数组、空成员空页)、`tests/test_selection_http.py`(新端点契约 + results 带 sector)。跑 `./dev.sh check`(ruff/pyright/pytest)。
## 二、前端(zhixing-web)
1. **布局重构** `selection-results-workbench.tsx`:
- 外层改为 `flex flex-col`:顶部一个全宽 section 放搜索栏(关键词 / 信号分类 / **板块(新增)** / 排序 / 筛选结果计数),带 `rounded-md border bg-card` 与现有一致;
- 下方 `md:grid md:grid-cols-[320px_minmax(0,1fr)]` 左列表右详情;移动端纵向堆叠为 搜索 → 列表 → 详情。
2. **类型与 API**(`selection.types.ts` / `selection.api.ts`):新增 `SelectionSectorAggregate`;`SelectionResultsQuery` 加 `sector?`;`getSelectionResultSectors()`;`buildSelectionQueryParams` 带 sector。
3. **查询层**(`selection.query.ts`):`useSelectionResultSectors`(key:strategy+date,结果就绪后启用);`selectionResultsQueryKey`/`selectionRunQueryKey` 加入 sector 使筛选变化触发重新请求。
4. **路由**(`routes/route-tree.tsx`):selectionRoute `validateSearch` 增加 `sector`(string,限长,默认 undefined)。
5. **页面接线**(`selection-results-page.tsx`):把 URL 中的 sector 传入 `useSelectionResults`;页面层调用 `useSelectionResultSectors` 并把聚合结果传给 workbench。
6. **workbench 板块下拉**:选项 = "全部板块" + 聚合数据,item 渲染 `名称 + 数量徽标`(数量 tabular-nums,倒序由后端保证);选择写 URL;聚合加载后若当前 sector 不在列表中(如切换策略/日期)自动重置为全部;板块无数据时下拉仅剩"全部板块"并禁用。
## 三、执行方式
- 按仓库 Trellis 工作流建任务目录并加载 `.trellis/spec/backend`(selection.md 契约:查询不触发重算、错误矩阵等)与前端规范后再动手;先后端(接口+测试)再前端接线,最后 `./dev.sh check` + 前端 lint/tsc 全量质量门禁。
- 语义说明:板块数量 = 当次 run 全部选中股票中归属该板块的数量(不随关键词/信号分类变化);列表"筛选结果 N 只" = 包含板块在内的全部过滤叠加后的 `stocks_total`。
+33
View File
@@ -123,3 +123,36 @@ docker compose -f docker-compose.prod.yml --profile jobs run --rm sector-radar-b
`moneyflow_dc` 先按交易日拉取全市场快照;即使首批达到 6000 行,也会根据上述候选股票检查实际覆盖,并用固定两路 worker 逐只补拉缺失代码。全市场请求、补拉和 retry 在同一 adapter 内共享 `ZHIXING_SECTOR_RADAR_REQUEST_INTERVAL_SECONDS`(默认 0.2 秒)的请求启动间隔;空分片或普通请求重试耗尽会保留为覆盖缺口并形成 `partial`,来源返回错误日期、错误代码、重复键或分片再次触顶则整次构建失败。该限流只在单进程 adapter 内生效,生产调度仍不得让 `market-data-sync` 与 `sector-radar-build` 重叠运行。 `moneyflow_dc` 先按交易日拉取全市场快照;即使首批达到 6000 行,也会根据上述候选股票检查实际覆盖,并用固定两路 worker 逐只补拉缺失代码。全市场请求、补拉和 retry 在同一 adapter 内共享 `ZHIXING_SECTOR_RADAR_REQUEST_INTERVAL_SECONDS`(默认 0.2 秒)的请求启动间隔;空分片或普通请求重试耗尽会保留为覆盖缺口并形成 `partial`,来源返回错误日期、错误代码、重复键或分片再次触顶则整次构建失败。该限流只在单进程 adapter 内生效,生产调度仍不得让 `market-data-sync` 与 `sector-radar-build` 重叠运行。
重复输入通过内容 hash 复用已有成功发布,不产生无意义修订;同一目标日由 PostgreSQL advisory lock 阻止并发构建。`success` 或 `unchanged` 返回 0,覆盖率不足的 `partial` 返回 2,输入、上游、锁或基础设施失败返回 1。`partial`/`failed` 会保留审计,但读取端只选择 `success` 作为 last-good。当前版本只提供手工和外部调度入口,不新增生产 Cron;待真实账号 capability、到达时点和首轮回填验证完成后再单独启用调度。 重复输入通过内容 hash 复用已有成功发布,不产生无意义修订;同一目标日由 PostgreSQL advisory lock 阻止并发构建。`success` 或 `unchanged` 返回 0,覆盖率不足的 `partial` 返回 2,输入、上游、锁或基础设施失败返回 1。`partial`/`failed` 会保留审计,但读取端只选择 `success` 作为 last-good。当前版本只提供手工和外部调度入口,不新增生产 Cron;待真实账号 capability、到达时点和首轮回填验证完成后再单独启用调度。
### 升级加权评分并重算历史
`--recompute` 只使用数据库中已有的板块聚合事实、交易日历和源快照,生成新的评分与排名发布;不调用 Tushare,不需要 token,不修改旧发布或历史成员。它与重新拉取上游的普通构建不同,不能和 `--retry-publication-id` 同用。
在生产项目目录中,将代码更新到包含加权评分的提交,沿用生产 `.env`,依次执行以下命令;每步成功后再执行下一步。Compose 为不同服务使用不同镜像名,因此除服务端和前端外,也要构建迁移与重算 Job 的新镜像。
```bash
# 构建新代码,暂不替换运行中的服务
docker compose -f docker-compose.prod.yml --profile jobs build \
server web migrate sector-radar-build
# 先增加 nullable weighted_score 列(0010),再启动新服务
docker compose -f docker-compose.prod.yml --profile jobs run --rm --no-deps migrate
docker compose -f docker-compose.prod.yml up -d server web
# 使用新 Job 镜像,按交易日顺序重算已有历史
docker compose -f docker-compose.prod.yml --profile jobs run --rm --no-deps sector-radar-build \
--recompute --start-date 2026-08-28 --end-date 2026-09-21
```
这里的日期是示例,应覆盖需要展示的已有历史,区间包含起止日。单日评分需要连续 5 个交易日成交额,波段评分需要连续 10 个交易日流入率;近 5 日波段排名变化还要求更早的对比日也有足够历史并已采用同一算法版本。首次升级建议从最早已有成功发布开始重算整个展示区间。缺少交易日、板块或可比版本时显示“—”,重算不会补造缺失数据。
输出的 `outcomes` 列出逐日结果;整个命令 `status` 为 `success` 或 `unchanged` 且退出码为 0 表示完成。相同输入重复运行返回 `unchanged`;失败会保留原 last-good,可修复原因后重跑同一区间。重算期间避免同时启动覆盖相同日期的普通构建或另一个重算任务。
以后只重算某一天,可复用已构建的新 Job 镜像:
```bash
docker compose -f docker-compose.prod.yml --profile jobs run --rm --no-deps sector-radar-build \
--recompute --trade-date 2026-09-21
```
`--no-deps` 用于上述已手动完成迁移的流程,避免再次启动依赖服务。`sector-radar-build` 服务配置了同名 entrypoint,后面直接传 `--recompute` 等参数即可。
Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

@@ -0,0 +1,27 @@
"""Keep weighted ranking scores separate from raw flow values."""
import sqlalchemy as sa
from alembic import op
revision = "0010_radar_weighted_score"
down_revision = "0009_radar_sector_detail"
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Preserve existing publication rows with an unknown (NULL) score."""
op.add_column(
"sector_radar_ranking", sa.Column("weighted_score", sa.Numeric(28, 12), nullable=True)
)
op.create_check_constraint(
"ck_radar_weighted_score_finite",
"sector_radar_ranking",
"weighted_score IS NULL OR weighted_score NOT IN "
"('NaN'::numeric, 'Infinity'::numeric, '-Infinity'::numeric)",
)
def downgrade() -> None:
"""Remove the additional score projection without changing raw values."""
op.drop_column("sector_radar_ranking", "weighted_score")
+5
View File
@@ -61,3 +61,8 @@ select = ["B", "E", "F", "I", "SIM", "UP"]
[tool.ruff.format] [tool.ruff.format]
quote-style = "double" quote-style = "double"
[[tool.uv.index]]
name = "tuna"
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
default = true
@@ -18,14 +18,10 @@ from zhixing_server.shared.request_coordinator import TushareSourceError
from ..domain.facts import aggregate_sector_snapshot from ..domain.facts import aggregate_sector_snapshot
from ..domain.metrics import ( from ..domain.metrics import (
AmountNetStrategy,
MetricStrategy, MetricStrategy,
RatioTurnoverStrategy,
SwingEqualThreeToTenStrategy,
) )
from ..domain.models import ( from ..domain.models import (
MembershipStatus, MembershipStatus,
MetricObservation,
PublicationStatus, PublicationStatus,
RadarPublication, RadarPublication,
RankedMetric, RankedMetric,
@@ -50,7 +46,6 @@ from ..domain.persistence import (
StockFactRecord, StockFactRecord,
) )
from ..domain.ports import ActiveMoneyflowSource, SectorRadarSource from ..domain.ports import ActiveMoneyflowSource, SectorRadarSource
from ..domain.ranking import rank_metric_observations, with_rank_changes
from ..domain.source import ( from ..domain.source import (
DailyRow, DailyRow,
MoneyflowDcRow, MoneyflowDcRow,
@@ -65,6 +60,7 @@ from ..domain.source import (
SuspendRow, SuspendRow,
TradeCalendarRow, TradeCalendarRow,
) )
from .scoring import calculate_rankings, calendar_rank_changes, default_strategies
BuildOutcomeStatus = Literal["success", "partial", "failed", "locked", "unchanged"] BuildOutcomeStatus = Literal["success", "partial", "failed", "locked", "unchanged"]
SHANGHAI = ZoneInfo("Asia/Shanghai") SHANGHAI = ZoneInfo("Asia/Shanghai")
@@ -194,14 +190,7 @@ class BuildSectorRadar:
self.repository = repository self.repository = repository
self.coverage_threshold = coverage_threshold self.coverage_threshold = coverage_threshold
self.today = today or self.now_fn().astimezone(SHANGHAI).date() self.today = today or self.now_fn().astimezone(SHANGHAI).date()
self.strategies = tuple( self.strategies = tuple(strategies) if strategies is not None else default_strategies()
strategies
or (
AmountNetStrategy(),
RatioTurnoverStrategy(),
SwingEqualThreeToTenStrategy(),
)
)
def execute(self, command: BuildSectorRadarCommand | None = None) -> BuildSummary: def execute(self, command: BuildSectorRadarCommand | None = None) -> BuildSummary:
"""Build each selected trade date sequentially for deterministic history.""" """Build each selected trade date sequentially for deterministic history."""
@@ -314,7 +303,13 @@ class BuildSectorRadar:
publication_created = True publication_created = True
reusable = self._reusable_sources(target.retry_publication_id) reusable = self._reusable_sources(target.retry_publication_id)
collected = self._collect(target.trade_date, publication_id, reusable) collected = self._collect(target.trade_date, publication_id, reusable)
input_hash = self._input_hash(collected.snapshots) previous_publications = tuple(
item
for item in self.repository.load_history_publications(target.trade_date)
if item.target_trade_date < target.trade_date
and item.source_version == self.source_version
)[:9]
input_hash = self._input_hash(collected.snapshots, previous_publications)
existing = self.repository.find_reusable_publication(target.trade_date, input_hash) existing = self.repository.find_reusable_publication(target.trade_date, input_hash)
if existing is not None: if existing is not None:
self.repository.discard_running_publication(publication_id) self.repository.discard_running_publication(publication_id)
@@ -339,7 +334,9 @@ class BuildSectorRadar:
input_hash=input_hash, input_hash=input_hash,
) )
aggregates = self._aggregate(collected) aggregates = self._aggregate(collected)
rankings = self._rank(target.trade_date, aggregates) rankings = self._rank(
target.trade_date, aggregates, collected.trading_dates, previous_publications
)
coverage = self._coverage(collected.stock_facts) coverage = self._coverage(collected.stock_facts)
membership_complete = all( membership_complete = all(
item.status is MembershipStatus.AVAILABLE for item in collected.memberships item.status is MembershipStatus.AVAILABLE for item in collected.memberships
@@ -638,6 +635,7 @@ class BuildSectorRadar:
) )
return _CollectedInputs( return _CollectedInputs(
target_trade_date=target, target_trade_date=target,
trading_dates=tuple(sorted({row.cal_date for row in calendar.rows if row.is_open})),
indices=concepts.rows + industries.rows, indices=concepts.rows + industries.rows,
snapshots=snapshots, snapshots=snapshots,
membership_snapshots=members.snapshots, membership_snapshots=members.snapshots,
@@ -692,24 +690,29 @@ class BuildSectorRadar:
return aggregates return aggregates
def _rank( def _rank(
self, target: date, aggregates: Sequence[SectorDailyAggregate] self,
target: date,
aggregates: Sequence[SectorDailyAggregate],
trading_dates: Sequence[date],
previous_publications: Sequence[RadarPublication],
) -> tuple[RankedMetric, ...]: ) -> tuple[RankedMetric, ...]:
history = tuple(self.repository.load_daily_aggregate_history(target, limit_dates=9)) history = tuple(
observations: list[MetricObservation] = [] record.aggregate
for current in aggregates: for record in self.repository.load_publication_aggregates(
sector_history = tuple( tuple(item.publication_id for item in previous_publications)
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) current_rankings = calculate_rankings(
history_by_days = {days: rankings for days, (_, rankings) in enumerate(previous, start=1)} target, aggregates, history, trading_dates, self.strategies
return with_rank_changes(current_rankings, history_by_days) )
dates_by_id = {
item.publication_id: item.target_trade_date for item in previous_publications[:5]
}
previous = {
dates_by_id[key]: rows
for key, rows in self.repository.load_publication_rankings(tuple(dates_by_id))
}
return calendar_rank_changes(current_rankings, previous, trading_dates, target)
@staticmethod @staticmethod
def _coverage(stock_facts: Sequence[StockFactRecord]) -> Decimal: def _coverage(stock_facts: Sequence[StockFactRecord]) -> Decimal:
@@ -750,12 +753,15 @@ class BuildSectorRadar:
groups.append(PublicationSourceGroup.MONEYFLOW_DC) groups.append(PublicationSourceGroup.MONEYFLOW_DC)
return tuple(groups) return tuple(groups)
def _input_hash(self, snapshots: Sequence[SourceSnapshot]) -> str: def _input_hash(
self, snapshots: Sequence[SourceSnapshot], previous: Sequence[RadarPublication] = ()
) -> str:
payload = json.dumps( payload = json.dumps(
{ {
"snapshot_ids": sorted(snapshot.snapshot_id for snapshot in snapshots), "snapshot_ids": sorted(snapshot.snapshot_id for snapshot in snapshots),
"metric_versions": sorted(strategy.metric_version for strategy in self.strategies), "metric_versions": sorted(strategy.metric_version for strategy in self.strategies),
"normalizer": "zhixing_stock_fact_v2", "normalizer": "zhixing_stock_fact_v2",
"history": [(item.publication_id, item.input_hash) for item in previous],
}, },
sort_keys=True, sort_keys=True,
separators=(",", ":"), separators=(",", ":"),
@@ -785,6 +791,7 @@ class BuildSectorRadar:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class _CollectedInputs: class _CollectedInputs:
target_trade_date: date target_trade_date: date
trading_dates: tuple[date, ...]
indices: tuple[SectorIndexRow, ...] indices: tuple[SectorIndexRow, ...]
snapshots: tuple[SourceSnapshot, ...] snapshots: tuple[SourceSnapshot, ...]
membership_snapshots: tuple[SourceSnapshot, ...] membership_snapshots: tuple[SourceSnapshot, ...]
@@ -7,7 +7,6 @@ from dataclasses import dataclass
from datetime import date from datetime import date
from decimal import Decimal from decimal import Decimal
from ..domain.metrics import AmountNetStrategy, RatioTurnoverStrategy, SwingEqualThreeToTenStrategy
from ..domain.models import MetricKind, RadarPublication, RankedMetric, RankSide, SectorType from ..domain.models import MetricKind, RadarPublication, RankedMetric, RankSide, SectorType
from ..domain.normalize import is_current_listed_stock from ..domain.normalize import is_current_listed_stock
from ..domain.persistence import HistoricalRanking, PublicationSourceGroup, SectorRadarRepository from ..domain.persistence import HistoricalRanking, PublicationSourceGroup, SectorRadarRepository
@@ -20,12 +19,7 @@ from ..domain.source import (
StockBasicRow, StockBasicRow,
TradeCalendarRow, TradeCalendarRow,
) )
from ..domain.weighted import resolve_metric_version
_METRIC_VERSIONS = {
MetricKind.AMOUNT: AmountNetStrategy.metric_version,
MetricKind.RATIO: RatioTurnoverStrategy.metric_version,
MetricKind.SWING: SwingEqualThreeToTenStrategy.metric_version,
}
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -50,6 +44,7 @@ class HistoryMetric:
missing: bool = True missing: bool = True
in_top: bool = False in_top: bool = False
in_bottom: bool = False in_bottom: bool = False
weighted_score: Decimal | None = None
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -134,7 +129,8 @@ class SectorDetail:
class _RankHistory: class _RankHistory:
"""Index one day's selected ranks once, retaining complete versioned pool counts.""" """Index one day's selected ranks once, retaining complete versioned pool counts."""
def __init__(self, records: Sequence[HistoricalRanking]) -> None: def __init__(self, records: Sequence[HistoricalRanking], versions: Sequence[str] = ()) -> None:
self.versions = tuple(versions) or tuple(record.metric_version for record in records)
self.pools: dict[tuple[SectorType, MetricKind, str], int] = {} self.pools: dict[tuple[SectorType, MetricKind, str], int] = {}
self.rankings: dict[tuple[SectorType, str, MetricKind, str], RankedMetric] = {} self.rankings: dict[tuple[SectorType, str, MetricKind, str], RankedMetric] = {}
self.names: dict[tuple[SectorType, str], str] = {} self.names: dict[tuple[SectorType, str], str] = {}
@@ -158,7 +154,9 @@ class _RankHistory:
) )
def metric(self, sector_type: SectorType, sector_code: str, kind: MetricKind) -> HistoryMetric: def metric(self, sector_type: SectorType, sector_code: str, kind: MetricKind) -> HistoryMetric:
version = _METRIC_VERSIONS[kind] version = resolve_metric_version(kind, self.versions)
if version is None:
return HistoryMetric()
size = self.pools.get((sector_type, kind, version), 0) size = self.pools.get((sector_type, kind, version), 0)
row = self.rankings.get((sector_type, sector_code, kind, version)) row = self.rankings.get((sector_type, sector_code, kind, version))
return _metric_from_ranking(row, size) return _metric_from_ranking(row, size)
@@ -187,9 +185,9 @@ class ReadRadarDetails:
): ):
by_id.setdefault(record.publication_id, []).append(record) by_id.setdefault(record.publication_id, []).append(record)
rows = { rows = {
day: _RankHistory(by_id.get(item.publication_id, ())) day: _RankHistory(by_id.get(item.publication_id, ()), publication.metric_versions)
if item.source_version == publication.source_version if item.source_version == publication.source_version
else _RankHistory(()) else _RankHistory((), publication.metric_versions)
for day, item in publications.items() for day, item in publications.items()
} }
sources = self.repository.load_publication_rows( sources = self.repository.load_publication_rows(
@@ -209,7 +207,11 @@ class ReadRadarDetails:
| set(publications) | set(publications)
)[-30:] )[-30:]
) )
return publications, {day: rows.get(day, _RankHistory(())) for day in dates}, dates return (
publications,
{day: rows.get(day, _RankHistory((), publication.metric_versions)) for day in dates},
dates,
)
def ranking_extras( def ranking_extras(
self, publication: RadarPublication, rankings: Sequence[RankedMetric], side: RankSide self, publication: RadarPublication, rankings: Sequence[RankedMetric], side: RankSide
@@ -438,12 +440,13 @@ def metric_at(
rows: Sequence[RankedMetric], sector_type: SectorType, sector_code: str, kind: MetricKind rows: Sequence[RankedMetric], sector_type: SectorType, sector_code: str, kind: MetricKind
) -> HistoryMetric: ) -> HistoryMetric:
"""Select compatible rank values; pool thresholds use each day's actual percentile.""" """Select compatible rank values; pool thresholds use each day's actual percentile."""
version = resolve_metric_version(kind, (row.observation.metric_version for row in rows))
pool = [ pool = [
row row
for row in rows for row in rows
if row.observation.sector_type is sector_type if row.observation.sector_type is sector_type
and row.observation.metric_kind is kind and row.observation.metric_kind is kind
and row.observation.metric_version == _METRIC_VERSIONS[kind] and row.observation.metric_version == version
] ]
size = sum(row.rank_position is not None for row in pool) size = sum(row.rank_position is not None for row in pool)
row = next((row for row in pool if row.observation.sector_code == sector_code), None) row = next((row for row in pool if row.observation.sector_code == sector_code), None)
@@ -463,6 +466,7 @@ def _metric_from_ranking(row: RankedMetric | None, size: int) -> HistoryMetric:
row.rank_position is None, row.rank_position is None,
percentile is not None and percentile >= 90, percentile is not None and percentile >= 90,
percentile is not None and percentile <= 10, percentile is not None and percentile <= 10,
row.observation.weighted_score,
) )
@@ -27,7 +27,13 @@ from ..domain.persistence import (
StockMembershipEntry, StockMembershipEntry,
) )
from ..domain.ranking import select_percentile_side, select_rank_change_side from ..domain.ranking import select_percentile_side, select_rank_change_side
from ..domain.weighted import (
RatioWeightedStrategy,
SwingWeightedStrategy,
resolve_metric_version,
)
from .details import RankingExtras, ReadRadarDetails from .details import RankingExtras, ReadRadarDetails
from .scoring import calendar_rank_changes, comparison_dates, publication_trading_dates
ReadStatus = Literal["success", "no_data"] ReadStatus = Literal["success", "no_data"]
@@ -62,7 +68,7 @@ class RadarQuery:
trade_date: date | None = None trade_date: date | None = None
sector_type: SectorType = SectorType.CONCEPT sector_type: SectorType = SectorType.CONCEPT
view: RadarView = RadarView.AMOUNT view: RadarView = RadarView.AMOUNT
rank_change_metric: MetricKind = MetricKind.AMOUNT rank_change_metric: MetricKind = MetricKind.SWING
rank_change_days: int = 1 rank_change_days: int = 1
side: RankSide = RankSide.ALL side: RankSide = RankSide.ALL
search: str | None = None search: str | None = None
@@ -108,6 +114,10 @@ class RankingPage:
rows: tuple[RankedMetric, ...] rows: tuple[RankedMetric, ...]
total: int total: int
extras: dict[str, RankingExtras] = field(default_factory=lambda: dict[str, RankingExtras]()) extras: dict[str, RankingExtras] = field(default_factory=lambda: dict[str, RankingExtras]())
rank_change_values: dict[str, dict[MetricKind, int | None]] = field(
default_factory=lambda: dict[str, dict[MetricKind, int | None]]()
)
comparison_trade_date: date | None = None
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -175,27 +185,54 @@ class SectorMembersSnapshot:
_METRIC_DEFINITIONS = { _METRIC_DEFINITIONS = {
MetricKind.AMOUNT: RadarMetricDefinition( AmountNetStrategy.metric_version: RadarMetricDefinition(
metric_kind=MetricKind.AMOUNT, metric_kind=MetricKind.AMOUNT,
metric_version=AmountNetStrategy.metric_version, metric_version=AmountNetStrategy.metric_version,
label="主力净流入(知行独立实现)", label="主力净流入(知行独立实现)",
unit=MetricUnit.CNY_100M, unit=MetricUnit.CNY_100M,
), ),
MetricKind.RATIO: RadarMetricDefinition( RatioTurnoverStrategy.metric_version: RadarMetricDefinition(
metric_kind=MetricKind.RATIO, metric_kind=MetricKind.RATIO,
metric_version=RatioTurnoverStrategy.metric_version, metric_version=RatioTurnoverStrategy.metric_version,
label="主力净流入/成交额(知行独立实现)", label="主力净流入/成交额(知行独立实现)",
unit=MetricUnit.RATIO, unit=MetricUnit.RATIO,
), ),
MetricKind.SWING: RadarMetricDefinition( SwingEqualThreeToTenStrategy.metric_version: RadarMetricDefinition(
metric_kind=MetricKind.SWING, metric_kind=MetricKind.SWING,
metric_version=SwingEqualThreeToTenStrategy.metric_version, metric_version=SwingEqualThreeToTenStrategy.metric_version,
label="3—10 日等权资金率(知行独立实现)", label="3—10 日等权资金率(知行独立实现)",
unit=MetricUnit.RATIO, unit=MetricUnit.RATIO,
), ),
RatioWeightedStrategy.metric_version: RadarMetricDefinition(
metric_kind=MetricKind.RATIO,
metric_version=RatioWeightedStrategy.metric_version,
label="单日流入率",
unit=MetricUnit.RATIO,
disclaimer="基于知行数据的资金排名与成交额加权评分",
),
SwingWeightedStrategy.metric_version: RadarMetricDefinition(
metric_kind=MetricKind.SWING,
metric_version=SwingWeightedStrategy.metric_version,
label="波段流入率",
unit=MetricUnit.RATIO,
disclaimer="3 日与 10 日资金率排名各占 50%,再按成交额加权",
),
} }
def metric_definition(
kind: MetricKind, publication: RadarPublication | None
) -> RadarMetricDefinition:
"""Resolve a publication's actual algorithm instead of hiding legacy rows."""
current = {
MetricKind.AMOUNT: AmountNetStrategy.metric_version,
MetricKind.RATIO: RatioWeightedStrategy.metric_version,
MetricKind.SWING: SwingWeightedStrategy.metric_version,
}[kind]
version = resolve_metric_version(kind, publication.metric_versions) if publication else None
return _METRIC_DEFINITIONS[version or current]
class ReadSectorRadar: class ReadSectorRadar:
"""Hide last-good selection, ranking filters, search, and pagination.""" """Hide last-good selection, ranking filters, search, and pagination."""
@@ -219,18 +256,45 @@ class ReadSectorRadar:
if query.view is RadarView.RANK_CHANGE if query.view is RadarView.RANK_CHANGE
else MetricKind(query.view.value) else MetricKind(query.view.value)
) )
definition = _METRIC_DEFINITIONS[metric_kind]
publication = ( publication = (
self.repository.get_successful_publication(query.trade_date) self.repository.get_successful_publication(query.trade_date)
if query.trade_date is not None if query.trade_date is not None
else self.repository.get_last_good_publication() else self.repository.get_last_good_publication()
) )
definition = metric_definition(metric_kind, publication)
if publication is None: if publication is None:
return RankingPage("no_data", query, None, definition, (), 0) return RankingPage("no_data", query, None, definition, (), 0)
all_rows = tuple(self.repository.load_rankings(publication.publication_id))
compared_date = None
if query.view is RadarView.RANK_CHANGE:
calendar = publication_trading_dates(
self.repository, publication.publication_id, publication.target_trade_date
)
previous_publications = tuple(
item
for item in self.repository.load_history_publications(publication.target_trade_date)
if item.target_trade_date < publication.target_trade_date
and item.source_version == publication.source_version
)[:5]
dates_by_id = {
item.publication_id: item.target_trade_date for item in previous_publications
}
previous = {
dates_by_id[publication_id]: rows
for publication_id, rows in self.repository.load_publication_rankings(
tuple(dates_by_id)
)
}
all_rows = calendar_rank_changes(
all_rows, previous, calendar, publication.target_trade_date
)
compared_date = comparison_dates(calendar, publication.target_trade_date)[
query.rank_change_days
]
metric_rows = tuple( metric_rows = tuple(
row row
for row in self.repository.load_rankings(publication.publication_id) for row in all_rows
if row.observation.sector_type is query.sector_type if row.observation.sector_type is query.sector_type
and row.observation.metric_kind is metric_kind and row.observation.metric_kind is metric_kind
and row.observation.metric_version == definition.metric_version and row.observation.metric_version == definition.metric_version
@@ -276,18 +340,32 @@ class ReadSectorRadar:
or search in row.observation.sector_name.casefold() or search in row.observation.sector_name.casefold()
) )
start = (query.page - 1) * query.page_size start = (query.page - 1) * query.page_size
page_rows = searched[start : start + query.page_size]
selected_codes = {row.observation.sector_code for row in page_rows}
changes: dict[str, dict[MetricKind, int | None]] = {}
for row in all_rows:
observation = row.observation
if (
observation.sector_type is query.sector_type
and observation.sector_code in selected_codes
and observation.metric_version
== resolve_metric_version(observation.metric_kind, publication.metric_versions)
):
changes.setdefault(observation.sector_code, {})[observation.metric_kind] = (
row.rank_change(query.rank_change_days)
)
return RankingPage( return RankingPage(
status="success", status="success",
query=query, query=query,
publication=publication, publication=publication,
definition=definition, definition=definition,
rows=searched[start : start + query.page_size], rows=page_rows,
total=len(searched), total=len(searched),
extras=ReadRadarDetails(self.repository).ranking_extras( extras=ReadRadarDetails(self.repository).ranking_extras(
publication, searched[start : start + query.page_size], query.side publication, page_rows, query.side
) ),
if query.view in {RadarView.AMOUNT, RadarView.RATIO} rank_change_values=changes,
else {}, comparison_trade_date=compared_date,
) )
def stock_membership(self, query: StockSectorQuery) -> StockSectorMembership: def stock_membership(self, query: StockSectorQuery) -> StockSectorMembership:
@@ -0,0 +1,268 @@
"""Rescore immutable local inputs without refetching or altering source history."""
from __future__ import annotations
import hashlib
import json
from collections.abc import Callable, Sequence
from dataclasses import replace
from datetime import UTC, date, datetime
from decimal import Decimal
from uuid import uuid4
from ..domain.models import PublicationStatus, RadarPublication, RankedMetric
from ..domain.persistence import (
DailyAggregateRecord,
PublicationSourceRecord,
RankingRecord,
SectorRadarRepository,
)
from .build import BuildDateOutcome, BuildSectorRadarCommand, BuildSummary
from .scoring import (
calculate_rankings,
calendar_rank_changes,
default_strategies,
publication_trading_dates,
)
class RecomputeSectorRadar:
"""Publish a new algorithm revision from pinned, already-normalized facts.
No source adapter is accepted: current stock membership cannot accidentally
replace a historical snapshot. Failed rebuilds leave the old last-good intact.
"""
def __init__(
self,
repository: SectorRadarRepository,
now_fn: Callable[[], datetime] = lambda: datetime.now(UTC),
) -> None:
self.repository = repository
self.now_fn = now_fn
self.versions = tuple(strategy.metric_version for strategy in default_strategies())
def execute(self, command: BuildSectorRadarCommand) -> BuildSummary:
"""Rescore successful dates oldest first, stopping if one date fails.
The requested range is inclusive. A single date or no date selects one
existing publication. All aggregate inputs are pinned before writes.
"""
if command.retry_publication_id is not None:
raise ValueError("offline recompute does not accept a source retry")
available = sorted(self.repository.list_successful_dates())
if command.trade_date is not None:
targets = [command.trade_date] if command.trade_date in available else []
elif command.start_date is not None and command.end_date is not None:
targets = [day for day in available if command.start_date <= day <= command.end_date]
else:
targets = available[-1:]
if not targets:
return BuildSummary(
(
BuildDateOutcome(
command.trade_date or command.start_date or self.now_fn().date(),
"failed",
None,
Decimal(0),
0,
0,
"no_local_publication",
"no successful local input publication",
),
)
)
needed = [day for day in available if day < targets[0]][-9:] + targets
pinned: dict[date, RadarPublication] = {}
for day in needed:
publication = self.repository.get_successful_publication(day)
if publication is None:
raise ValueError("selected local publication disappeared")
pinned[day] = publication
inputs = self.repository.load_publication_aggregates(
tuple(item.publication_id for item in pinned.values())
)
by_publication: dict[str, list[DailyAggregateRecord]] = {}
for record in inputs:
by_publication.setdefault(record.publication_id, []).append(record)
existing_rankings = dict(
self.repository.load_publication_rankings(
tuple(item.publication_id for item in pinned.values())
)
)
ranks_by_day = {
day: existing_rankings.get(item.publication_id, ()) for day, item in pinned.items()
}
outcomes: list[BuildDateOutcome] = []
for target in targets:
base = pinned[target]
current = by_publication.get(base.publication_id, [])
prior_dates = [day for day in needed if day < target][-9:]
history = [
record
for day in prior_dates
if pinned[day].source_version == base.source_version
for record in by_publication.get(pinned[day].publication_id, [])
]
previous = {
day: ranks_by_day[day]
for day in prior_dates
if pinned[day].source_version == base.source_version
}
outcome, rankings = self._rescore(base, current, history, previous)
outcomes.append(outcome)
if outcome.status in {"failed", "locked"}:
break
ranks_by_day[target] = rankings
return BuildSummary(tuple(outcomes))
def _rescore(
self,
base: RadarPublication,
current: Sequence[DailyAggregateRecord],
history: Sequence[DailyAggregateRecord],
previous: dict[date, Sequence[RankedMetric]],
) -> tuple[BuildDateOutcome, Sequence[RankedMetric]]:
target = base.target_trade_date
pending: RadarPublication | None = None
try:
with self.repository.advisory_lock(target) as acquired:
if not acquired:
return BuildDateOutcome(target, "locked", None, Decimal(0), 0, 0), ()
if not current:
raise ValueError("local aggregate input is missing")
calendar = publication_trading_dates(self.repository, base.publication_id, target)
rankings = calendar_rank_changes(
calculate_rankings(
target,
[item.aggregate for item in current],
[item.aggregate for item in history],
calendar,
),
previous,
calendar,
target,
)
sources = tuple(self.repository.load_publication_sources(base.publication_id))
digest = self._fingerprint(base, current, history, sources, rankings)
reusable = self.repository.find_reusable_publication(target, digest)
if reusable is not None and reusable.status is PublicationStatus.SUCCESS:
return BuildDateOutcome(
target,
"unchanged",
reusable.publication_id,
reusable.coverage,
len(current),
len(rankings),
), rankings
started = self.now_fn()
self.repository.recover_running_publications(target, finished_at=started)
pending = replace(
base,
publication_id=f"radar-{target:%Y%m%d}-rescore-{uuid4().hex[:20]}",
status=PublicationStatus.RUNNING,
metric_versions=self.versions,
input_hash=None,
started_at=started,
finished_at=None,
error_summary=None,
)
self.repository.create_publication(pending)
self.repository.save_publication_sources(
replace(item, publication_id=pending.publication_id, refresh_on_retry=False)
for item in sources
)
finished = replace(
pending,
status=PublicationStatus.SUCCESS,
input_hash=digest,
finished_at=self.now_fn(),
)
self.repository.finalize_publication(
finished,
memberships=(),
stock_facts=(),
daily_aggregates=(
replace(item, publication_id=pending.publication_id) for item in current
),
rankings=(RankingRecord(pending.publication_id, row) for row in rankings),
)
return BuildDateOutcome(
target,
"success",
pending.publication_id,
base.coverage,
len(current),
len(rankings),
), rankings
except (ValueError, RuntimeError, ArithmeticError) as exc:
if pending is not None:
saved = self.repository.get_publication(pending.publication_id)
if saved is not None and saved.status is PublicationStatus.RUNNING:
self.repository.finish_publication(
replace(
pending,
status=PublicationStatus.FAILED,
finished_at=self.now_fn(),
error_summary="offline_recompute_failed",
)
)
return BuildDateOutcome(
target,
"failed",
pending.publication_id if pending else None,
Decimal(0),
0,
0,
type(exc).__name__,
"local radar rescoring failed",
), ()
def _fingerprint(
self,
base: RadarPublication,
current: Sequence[DailyAggregateRecord],
history: Sequence[DailyAggregateRecord],
sources: Sequence[PublicationSourceRecord],
rankings: Sequence[RankedMetric],
) -> str:
"""Hash source content, not derived publication IDs, so reruns are idempotent."""
payload = {
"source_version": base.source_version,
"universe_version": base.universe_version,
"metric_versions": self.versions,
"snapshots": sorted(item.snapshot.snapshot_id for item in sources),
"inputs": [
(
item.aggregate.trade_date.isoformat(),
item.aggregate.sector_type.value,
item.aggregate.sector_code,
str(item.aggregate.net_amount_yuan),
str(item.aggregate.turnover_yuan),
item.aggregate.member_count,
item.aggregate.valid_sample_count,
str(item.aggregate.membership_coverage),
str(item.aggregate.moneyflow_coverage),
str(item.pct_change),
item.leading_code,
)
for item in sorted(
(*history, *current),
key=lambda item: (
item.aggregate.trade_date,
item.aggregate.sector_type,
item.aggregate.sector_code,
),
)
],
"rank_changes": [
(
row.observation.sector_type.value,
row.observation.sector_code,
row.observation.metric_version,
[(change.days, change.value) for change in row.rank_changes],
)
for row in rankings
],
}
return hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()
@@ -0,0 +1,98 @@
"""Shared publication scoring and strict trading-session comparisons."""
from __future__ import annotations
from collections import defaultdict
from collections.abc import Mapping, Sequence
from datetime import date
from ..domain.metrics import AmountNetStrategy, MetricStrategy
from ..domain.models import MetricObservation, RankedMetric, SectorDailyAggregate, SectorType
from ..domain.persistence import PublicationSourceGroup, SectorRadarRepository
from ..domain.ranking import rank_metric_observations, with_rank_changes
from ..domain.source import TradeCalendarRow
from ..domain.weighted import (
FlowFeatures,
RatioWeightedStrategy,
SwingWeightedStrategy,
attach_weighted_scores,
calendar_sector_history,
flow_features,
)
def default_strategies() -> tuple[MetricStrategy, ...]:
"""Return versioned strategies shared by online builds and offline rescoring."""
return (AmountNetStrategy(), RatioWeightedStrategy(), SwingWeightedStrategy())
def publication_trading_dates(
repository: SectorRadarRepository,
publication_id: str,
target: date,
) -> tuple[date, ...]:
"""Read the exact publication's calendar without querying an external provider."""
sources = repository.load_publication_rows(publication_id, (PublicationSourceGroup.CALENDAR,))
return tuple(
sorted(
{
parsed.cal_date
for row in sources.get(PublicationSourceGroup.CALENDAR, ())
for parsed in (TradeCalendarRow.from_mapping(row),)
if parsed.is_open and parsed.cal_date <= target
}
)
)
def calculate_rankings(
target: date,
aggregates: Sequence[SectorDailyAggregate],
history: Sequence[SectorDailyAggregate],
trading_dates: Sequence[date],
strategies: Sequence[MetricStrategy] | None = None,
) -> tuple[RankedMetric, ...]:
"""Evaluate raw features, score full pools, then generate authoritative ranks."""
histories: defaultdict[tuple[SectorType, str], dict[date, SectorDailyAggregate]] = defaultdict(
dict
)
for row in history:
if row.trade_date >= target:
continue
days = histories[(row.sector_type, row.sector_code)]
if row.trade_date in days:
raise ValueError("aggregate history must contain unique sector dates")
days[row.trade_date] = row
observations: list[MetricObservation] = []
features: dict[tuple[SectorType, str], FlowFeatures] = {}
selected = default_strategies() if strategies is None else strategies
for current in aggregates:
if current.trade_date != target:
raise ValueError("current aggregates must match the target date")
key = (current.sector_type, current.sector_code)
sector_history = calendar_sector_history(current, histories[key], trading_dates)
features[key] = flow_features(sector_history, target)
observations.extend(strategy.evaluate(sector_history, target) for strategy in selected)
return rank_metric_observations(attach_weighted_scores(observations, features))
def comparison_dates(trading_dates: Sequence[date], target: date) -> dict[int, date | None]:
"""Resolve actual prior trading sessions; missing calendar history stays unknown."""
previous = sorted({day for day in trading_dates if day < target}, reverse=True)
return {days: previous[days - 1] if len(previous) >= days else None for days in range(1, 6)}
def calendar_rank_changes(
current: Sequence[RankedMetric],
previous: Mapping[date, Sequence[RankedMetric]],
trading_dates: Sequence[date],
target: date,
) -> tuple[RankedMetric, ...]:
"""Compare matching versions on exact calendar dates, without skipping failed days."""
return with_rank_changes(
current,
{
days: previous.get(day, ()) if day is not None else ()
for days, day in comparison_dates(trading_dates, target).items()
},
)
@@ -33,22 +33,24 @@ class MetricStrategy(Protocol):
... ...
def _target_aggregate( def target_aggregate(
history: Iterable[SectorDailyAggregate], target_trade_date: date history: Iterable[SectorDailyAggregate], target_trade_date: date
) -> SectorDailyAggregate: ) -> SectorDailyAggregate:
"""Require one target row; missing or duplicate targets are invalid inputs."""
matches = tuple(row for row in history if row.trade_date == target_trade_date) matches = tuple(row for row in history if row.trade_date == target_trade_date)
if len(matches) != 1: if len(matches) != 1:
raise ValueError("history must contain exactly one target-date aggregate") raise ValueError("history must contain exactly one target-date aggregate")
return matches[0] return matches[0]
def _quality(row: SectorDailyAggregate) -> MetricQuality: def aggregate_quality(row: SectorDailyAggregate) -> MetricQuality:
"""Retain limited-sample status for incomplete membership or flow coverage."""
if row.valid_sample_count < 5 or row.membership_coverage < 1 or row.moneyflow_coverage < 1: if row.valid_sample_count < 5 or row.membership_coverage < 1 or row.moneyflow_coverage < 1:
return MetricQuality.AVAILABLE_LIMITED_SAMPLE return MetricQuality.AVAILABLE_LIMITED_SAMPLE
return MetricQuality.AVAILABLE return MetricQuality.AVAILABLE
def _observation( def make_metric_observation(
row: SectorDailyAggregate, row: SectorDailyAggregate,
*, *,
metric_kind: MetricKind, metric_kind: MetricKind,
@@ -57,6 +59,7 @@ def _observation(
value: Decimal | None, value: Decimal | None,
quality: MetricQuality | None = None, quality: MetricQuality | None = None,
) -> MetricObservation: ) -> MetricObservation:
"""Carry source coverage into a metric and mark unknown raw values unavailable."""
return MetricObservation( return MetricObservation(
trade_date=row.trade_date, trade_date=row.trade_date,
sector_type=row.sector_type, sector_type=row.sector_type,
@@ -72,7 +75,7 @@ def _observation(
if value is None if value is None
else quality else quality
if quality is not None if quality is not None
else _quality(row) else aggregate_quality(row)
), ),
member_count=row.member_count, member_count=row.member_count,
valid_sample_count=row.valid_sample_count, valid_sample_count=row.valid_sample_count,
@@ -95,9 +98,9 @@ class AmountNetStrategy:
) -> MetricObservation: ) -> MetricObservation:
"""Return the target net amount; missing moneyflow remains unavailable.""" """Return the target net amount; missing moneyflow remains unavailable."""
row = _target_aggregate(history, target_trade_date) row = target_aggregate(history, target_trade_date)
value = None if row.net_amount_yuan is None else row.net_amount_yuan / Decimal("100000000") value = None if row.net_amount_yuan is None else row.net_amount_yuan / Decimal("100000000")
return _observation( return make_metric_observation(
row, row,
metric_kind=self.metric_kind, metric_kind=self.metric_kind,
metric_version=self.metric_version, metric_version=self.metric_version,
@@ -120,7 +123,7 @@ class RatioTurnoverStrategy:
) -> MetricObservation: ) -> MetricObservation:
"""Return a ratio only when numerator and positive denominator exist.""" """Return a ratio only when numerator and positive denominator exist."""
row = _target_aggregate(history, target_trade_date) row = target_aggregate(history, target_trade_date)
value = None value = None
if ( if (
row.net_amount_yuan is not None row.net_amount_yuan is not None
@@ -128,7 +131,7 @@ class RatioTurnoverStrategy:
and row.turnover_yuan > 0 and row.turnover_yuan > 0
): ):
value = row.net_amount_yuan / row.turnover_yuan value = row.net_amount_yuan / row.turnover_yuan
return _observation( return make_metric_observation(
row, row,
metric_kind=self.metric_kind, metric_kind=self.metric_kind,
metric_version=self.metric_version, metric_version=self.metric_version,
@@ -156,7 +159,7 @@ class SwingEqualThreeToTenStrategy:
"""Calculate eight complete trading-day windows ending at the target.""" """Calculate eight complete trading-day windows ending at the target."""
rows = tuple(sorted(history, key=lambda row: row.trade_date)) rows = tuple(sorted(history, key=lambda row: row.trade_date))
target = _target_aggregate(rows, target_trade_date) target = target_aggregate(rows, target_trade_date)
eligible = tuple(row for row in rows if row.trade_date <= target_trade_date) eligible = tuple(row for row in rows if row.trade_date <= target_trade_date)
if any( if any(
(row.sector_type, row.sector_code) != (target.sector_type, target.sector_code) (row.sector_type, row.sector_code) != (target.sector_type, target.sector_code)
@@ -190,11 +193,11 @@ class SwingEqualThreeToTenStrategy:
value = sum(window_ratios, start=Decimal(0)) / Decimal(8) value = sum(window_ratios, start=Decimal(0)) / Decimal(8)
quality = ( quality = (
MetricQuality.AVAILABLE_LIMITED_SAMPLE MetricQuality.AVAILABLE_LIMITED_SAMPLE
if any(_quality(row) is not MetricQuality.AVAILABLE for row in latest) if any(aggregate_quality(row) is not MetricQuality.AVAILABLE for row in latest)
else MetricQuality.AVAILABLE else MetricQuality.AVAILABLE
) )
return _observation( return make_metric_observation(
target, target,
metric_kind=self.metric_kind, metric_kind=self.metric_kind,
metric_version=self.metric_version, metric_version=self.metric_version,
@@ -260,11 +260,15 @@ class MetricObservation:
valid_sample_count: int valid_sample_count: int
membership_coverage: Decimal membership_coverage: Decimal
moneyflow_coverage: Decimal moneyflow_coverage: Decimal
weighted_score: Decimal | None = None
def __post_init__(self) -> None: def __post_init__(self) -> None:
"""Keep unavailable and finite-value states internally consistent.""" """Keep unavailable and finite-value states internally consistent."""
_validate_finite_decimal(self.value, "value") _validate_finite_decimal(self.value, "value")
_validate_finite_decimal(self.weighted_score, "weighted_score")
if self.weighted_score is not None and self.value is None:
raise ValueError("a weighted score requires an observed raw value")
if self.value is None and self.quality is not MetricQuality.UNAVAILABLE: if self.value is None and self.quality is not MetricQuality.UNAVAILABLE:
raise ValueError("a missing metric value must be unavailable") raise ValueError("a missing metric value must be unavailable")
if self.value is not None and self.quality is MetricQuality.UNAVAILABLE: if self.value is not None and self.quality is MetricQuality.UNAVAILABLE:
@@ -293,6 +293,12 @@ class SectorRadarRepository(Protocol):
def save_daily_aggregates(self, records: Iterable[DailyAggregateRecord]) -> WriteCounts: ... def save_daily_aggregates(self, records: Iterable[DailyAggregateRecord]) -> WriteCounts: ...
def load_publication_aggregates(
self, publication_ids: Sequence[str]
) -> Sequence[DailyAggregateRecord]:
"""Read exact revisions, retaining source details for offline rescoring."""
...
def create_publication(self, publication: RadarPublication) -> WriteCounts: ... def create_publication(self, publication: RadarPublication) -> WriteCounts: ...
def finish_publication(self, publication: RadarPublication) -> None: ... def finish_publication(self, publication: RadarPublication) -> None: ...
@@ -16,6 +16,7 @@ from .models import (
RankSide, RankSide,
SectorType, SectorType,
) )
from .weighted import ranking_value
PoolKey = tuple[date, SectorType, MetricKind, str] PoolKey = tuple[date, SectorType, MetricKind, str]
SectorMetricKey = tuple[SectorType, str, MetricKind, str] SectorMetricKey = tuple[SectorType, str, MetricKind, str]
@@ -40,9 +41,10 @@ def _sector_metric_key(observation: MetricObservation) -> SectorMetricKey:
def _available_sort_key(observation: MetricObservation) -> tuple[Decimal, str]: def _available_sort_key(observation: MetricObservation) -> tuple[Decimal, str]:
if observation.value is None: value = ranking_value(observation)
if value is None:
raise ValueError("unavailable observations cannot use the ranking sort key") raise ValueError("unavailable observations cannot use the ranking sort key")
return (-observation.value, observation.sector_code) return (-value, observation.sector_code)
def rank_metric_observations( def rank_metric_observations(
@@ -70,7 +72,7 @@ def rank_metric_observations(
raise ValueError("a ranking pool must not contain duplicate sector codes") raise ValueError("a ranking pool must not contain duplicate sector codes")
available = sorted( available = sorted(
(observation for observation in pool if observation.value is not None), (observation for observation in pool if ranking_value(observation) is not None),
key=_available_sort_key, key=_available_sort_key,
) )
pool_size = len(available) pool_size = len(available)
@@ -93,7 +95,7 @@ def rank_metric_observations(
rank_percentile=None, rank_percentile=None,
) )
for observation in sorted( for observation in sorted(
(observation for observation in pool if observation.value is None), (observation for observation in pool if ranking_value(observation) is None),
key=lambda observation: observation.sector_code, key=lambda observation: observation.sector_code,
) )
) )
@@ -134,7 +136,7 @@ def select_percentile_side(
sorted( sorted(
pools[pool_key], pools[pool_key],
key=lambda row: ( key=lambda row: (
row.observation.value if row.observation.value is not None else Decimal(0), ranking_value(row.observation) or Decimal(0),
row.observation.sector_code, row.observation.sector_code,
), ),
) )
@@ -0,0 +1,246 @@
"""Versioned flow scores: separate observable ratios from cross-sectional scores."""
from __future__ import annotations
from collections import defaultdict
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass, replace
from datetime import date
from decimal import Decimal
from .metrics import (
AmountNetStrategy,
RatioTurnoverStrategy,
SwingEqualThreeToTenStrategy,
aggregate_quality,
make_metric_observation,
target_aggregate,
)
from .models import (
MetricKind,
MetricObservation,
MetricQuality,
MetricUnit,
SectorDailyAggregate,
SectorType,
)
RATIO_WEIGHTED_VERSION = "zhixing_ratio_weighted_v2"
SWING_WEIGHTED_VERSION = "zhixing_swing_weighted_v2"
WEIGHTED_VERSIONS = frozenset((RATIO_WEIGHTED_VERSION, SWING_WEIGHTED_VERSION))
SectorKey = tuple[SectorType, str]
def resolve_metric_version(kind: MetricKind, versions: Iterable[str]) -> str | None:
"""Select a supported version actually present in an immutable publication."""
available = set(versions)
candidates = {
MetricKind.AMOUNT: (AmountNetStrategy.metric_version,),
MetricKind.RATIO: (RATIO_WEIGHTED_VERSION, RatioTurnoverStrategy.metric_version),
MetricKind.SWING: (SWING_WEIGHTED_VERSION, SwingEqualThreeToTenStrategy.metric_version),
}
return next((version for version in candidates[kind] if version in available), None)
def ranking_value(observation: MetricObservation) -> Decimal | None:
"""Never substitute a raw ratio for a missing score during v2 warm-up."""
return (
observation.weighted_score
if observation.metric_version in WEIGHTED_VERSIONS
else observation.value
)
def daily_flow_ratio(row: SectorDailyAggregate) -> Decimal | None:
"""Use yuan consistently, with the reviewed 100-yuan denominator offset."""
if row.net_amount_yuan is None or row.turnover_yuan is None or row.turnover_yuan <= 0:
return None
return row.net_amount_yuan / (row.turnover_yuan + Decimal(100))
@dataclass(frozen=True, slots=True)
class FlowFeatures:
"""Past-only inputs before any ranking or page filtering is applied."""
daily_ratio: Decimal | None
short_ratio: Decimal | None
long_ratio: Decimal | None
liquidity_weight: Decimal | None
quality: MetricQuality
def flow_features(history: Iterable[SectorDailyAggregate], target: date) -> FlowFeatures:
"""Extract complete 3/5/10-session windows; unknown inputs stay unknown.
The caller supplies calendar-aligned history, including explicit missing
aggregates for calendar holes. Future observations never enter a window.
"""
rows = tuple(
sorted((row for row in history if row.trade_date <= target), key=lambda row: row.trade_date)
)
current = target_aggregate(rows, target)
if len({row.trade_date for row in rows}) != len(rows):
raise ValueError("history must not contain duplicate trade dates")
if any(
(row.sector_type, row.sector_code) != (current.sector_type, current.sector_code)
for row in rows
):
raise ValueError("history must contain exactly one sector identity")
def mean_ratio(window: int) -> Decimal | None:
ratios = tuple(daily_flow_ratio(row) for row in rows[-window:])
if len(ratios) < window or any(value is None for value in ratios):
return None
return sum((value for value in ratios if value is not None), Decimal(0)) / window
turnovers = tuple(row.turnover_yuan for row in rows[-5:])
weight = None
if len(turnovers) == 5 and all(value is not None and value > 0 for value in turnovers):
average = sum((value for value in turnovers if value is not None), Decimal(0)) / 5
weight = (average + 1).log10() / 10
quality = (
MetricQuality.AVAILABLE_LIMITED_SAMPLE
if any(aggregate_quality(row) is not MetricQuality.AVAILABLE for row in rows[-10:])
else MetricQuality.AVAILABLE
)
return FlowFeatures(daily_flow_ratio(current), mean_ratio(3), mean_ratio(10), weight, quality)
class RatioWeightedStrategy:
"""Keep the raw daily ratio; attach its score only after observing the full pool."""
metric_kind = MetricKind.RATIO
metric_version = RATIO_WEIGHTED_VERSION
unit = MetricUnit.RATIO
def evaluate(
self, history: Iterable[SectorDailyAggregate], target_trade_date: date
) -> MetricObservation:
"""Return a daily observation without pretending that a score is a ratio."""
rows = tuple(history)
current = target_aggregate(rows, target_trade_date)
features = flow_features(rows, target_trade_date)
return make_metric_observation(
current,
metric_kind=self.metric_kind,
metric_version=self.metric_version,
unit=self.unit,
value=features.daily_ratio,
quality=features.quality,
)
class SwingWeightedStrategy:
"""Combine 3- and 10-session simple means, equally weighted."""
metric_kind = MetricKind.SWING
metric_version = SWING_WEIGHTED_VERSION
unit = MetricUnit.RATIO
def evaluate(
self, history: Iterable[SectorDailyAggregate], target_trade_date: date
) -> MetricObservation:
"""Expose the mean ratio independently of the two percentile ranks."""
rows = tuple(history)
current = target_aggregate(rows, target_trade_date)
features = flow_features(rows, target_trade_date)
value = (
(features.short_ratio + features.long_ratio) / 2
if features.short_ratio is not None and features.long_ratio is not None
else None
)
return make_metric_observation(
current,
metric_kind=self.metric_kind,
metric_version=self.metric_version,
unit=self.unit,
value=value,
quality=features.quality,
)
def calendar_sector_history(
current: SectorDailyAggregate,
previous: Mapping[date, SectorDailyAggregate],
trading_dates: Sequence[date],
) -> tuple[SectorDailyAggregate, ...]:
"""Represent missing trading sessions explicitly rather than skipping them."""
dates = sorted({day for day in trading_dates if day <= current.trade_date})[-10:]
if not dates or dates[-1] != current.trade_date:
raise ValueError("the target must belong to the observed trading calendar")
return tuple(
current
if day == current.trade_date
else previous.get(day)
or replace(
current,
trade_date=day,
member_count=0,
valid_sample_count=0,
net_amount_yuan=None,
turnover_yuan=None,
membership_coverage=Decimal(0),
moneyflow_coverage=Decimal(0),
)
for day in dates
)
def _percentiles(values: Mapping[str, Decimal | None]) -> dict[str, Decimal]:
"""Compute ascending average-rank percentiles, preserving ties deterministically."""
ordered = sorted((value, code) for code, value in values.items() if value is not None)
result: dict[str, Decimal] = {}
start = 0
while start < len(ordered):
end = start + 1
while end < len(ordered) and ordered[end][0] == ordered[start][0]:
end += 1
percentile = Decimal(start + 1 + end) / (2 * len(ordered))
result.update((code, percentile) for _, code in ordered[start:end])
start = end
return result
def attach_weighted_scores(
observations: Sequence[MetricObservation],
features: Mapping[SectorKey, FlowFeatures],
) -> tuple[MetricObservation, ...]:
"""Score complete date/type/version pools, never a paginated subset.
Missing history can leave a raw value available without a final rank.
The two swing percentiles are calculated separately before combining.
"""
pools: defaultdict[tuple[date, SectorType, str], list[MetricObservation]] = defaultdict(list)
for row in observations:
if row.metric_version in WEIGHTED_VERSIONS:
pools[(row.trade_date, row.sector_type, row.metric_version)].append(row)
scores: dict[tuple[date, SectorType, str, str], Decimal | None] = {}
for pool in pools.values():
feature = {row.sector_code: features[(row.sector_type, row.sector_code)] for row in pool}
daily = _percentiles({code: item.daily_ratio for code, item in feature.items()})
short = _percentiles({code: item.short_ratio for code, item in feature.items()})
long = _percentiles({code: item.long_ratio for code, item in feature.items()})
for row in pool:
code = row.sector_code
weight = feature[code].liquidity_weight
percentile = (
daily.get(code)
if row.metric_kind is MetricKind.RATIO
else ((short[code] + long[code]) / 2 if code in short and code in long else None)
)
scores[(row.trade_date, row.sector_type, code, row.metric_version)] = (
Decimal(1000) * percentile * weight
if percentile is not None and weight is not None and row.value is not None
else None
)
return tuple(
replace(
row,
weighted_score=scores[
(row.trade_date, row.sector_type, row.sector_code, row.metric_version)
],
)
if row.metric_version in WEIGHTED_VERSIONS
else row
for row in observations
)
@@ -254,6 +254,26 @@ class InMemorySectorRadarRepository:
), ),
) )
def load_publication_aggregates(
self, publication_ids: Sequence[str]
) -> Sequence[DailyAggregateRecord]:
"""Load exact immutable aggregate revisions, including detail fields."""
wanted = set(publication_ids)
return tuple(
sorted(
(
record
for record in self.daily_aggregates.values()
if record.publication_id in wanted
),
key=lambda record: (
record.aggregate.trade_date,
record.aggregate.sector_type,
record.aggregate.sector_code,
),
)
)
def create_publication(self, publication: RadarPublication) -> WriteCounts: def create_publication(self, publication: RadarPublication) -> WriteCounts:
"""Create one running publication without replacing an existing identity.""" """Create one running publication without replacing an existing identity."""
@@ -493,6 +493,34 @@ class PostgresSectorRadarRepository:
rows, rows,
) )
def load_publication_aggregates(
self, publication_ids: Sequence[str]
) -> Sequence[DailyAggregateRecord]:
"""Batch-read exact input revisions without dropping detail provenance."""
if not publication_ids:
return ()
with self._connection() as connection:
rows = connection.execute(
"""
SELECT publication_id, trade_date, sector_type, sector_code, sector_name,
member_count, valid_sample_count, net_amount_yuan, turnover_yuan,
membership_coverage, moneyflow_coverage, pct_change, leading_code
FROM sector_radar_daily_aggregate
WHERE publication_id = ANY(%s)
ORDER BY trade_date, sector_type, sector_code
""",
(list(publication_ids),),
).fetchall()
return tuple(
DailyAggregateRecord(
str(row[0]),
self._aggregate_from_row(row[1:11]),
None if row[11] is None else Decimal(str(row[11])),
None if row[12] is None else str(row[12]),
)
for row in rows
)
def create_publication(self, publication: RadarPublication) -> WriteCounts: def create_publication(self, publication: RadarPublication) -> WriteCounts:
"""Insert a new running publication identity idempotently.""" """Insert a new running publication identity idempotently."""
@@ -694,6 +722,7 @@ class PostgresSectorRadarRepository:
"rank_position", "rank_position",
"rank_percentile", "rank_percentile",
"rank_changes", "rank_changes",
"weighted_score",
), ),
("publication_id", "sector_type", "sector_code", "metric_version"), ("publication_id", "sector_type", "sector_code", "metric_version"),
self._ranking_rows(ranking_items), self._ranking_rows(ranking_items),
@@ -772,6 +801,7 @@ class PostgresSectorRadarRepository:
"rank_position", "rank_position",
"rank_percentile", "rank_percentile",
"rank_changes", "rank_changes",
"weighted_score",
), ),
("publication_id", "sector_type", "sector_code", "metric_version"), ("publication_id", "sector_type", "sector_code", "metric_version"),
self._ranking_rows(items), self._ranking_rows(items),
@@ -877,7 +907,8 @@ class PostgresSectorRadarRepository:
SELECT trade_date, sector_type, sector_code, sector_name, metric_kind, SELECT trade_date, sector_type, sector_code, sector_name, metric_kind,
metric_version, implementation_kind, unit, metric_value, quality, metric_version, implementation_kind, unit, metric_value, quality,
member_count, valid_sample_count, membership_coverage, member_count, valid_sample_count, membership_coverage,
moneyflow_coverage, rank_position, rank_percentile, rank_changes moneyflow_coverage, rank_position, rank_percentile, rank_changes,
weighted_score
FROM sector_radar_ranking FROM sector_radar_ranking
WHERE publication_id = %s WHERE publication_id = %s
ORDER BY sector_type, metric_version, rank_position NULLS LAST, sector_code ORDER BY sector_type, metric_version, rank_position NULLS LAST, sector_code
@@ -898,7 +929,8 @@ class PostgresSectorRadarRepository:
SELECT publication_id, trade_date, sector_type, sector_code, sector_name, SELECT publication_id, trade_date, sector_type, sector_code, sector_name,
metric_kind, metric_version, implementation_kind, unit, metric_value, metric_kind, metric_version, implementation_kind, unit, metric_value,
quality, member_count, valid_sample_count, membership_coverage, quality, member_count, valid_sample_count, membership_coverage,
moneyflow_coverage, rank_position, rank_percentile, rank_changes moneyflow_coverage, rank_position, rank_percentile, rank_changes,
weighted_score
FROM sector_radar_ranking FROM sector_radar_ranking
WHERE publication_id = ANY(%s) WHERE publication_id = ANY(%s)
ORDER BY publication_id, sector_type, metric_kind, rank_position NULLS LAST, ORDER BY publication_id, sector_type, metric_kind, rank_position NULLS LAST,
@@ -938,7 +970,8 @@ class PostgresSectorRadarRepository:
ranking.implementation_kind, ranking.unit, ranking.metric_value, ranking.implementation_kind, ranking.unit, ranking.metric_value,
ranking.quality, ranking.member_count, ranking.valid_sample_count, ranking.quality, ranking.member_count, ranking.valid_sample_count,
ranking.membership_coverage, ranking.moneyflow_coverage, ranking.membership_coverage, ranking.moneyflow_coverage,
ranking.rank_position, ranking.rank_percentile, ranking.rank_changes ranking.rank_position, ranking.rank_percentile, ranking.rank_changes,
ranking.weighted_score
FROM pools AS pool FROM pools AS pool
LEFT JOIN sector_radar_ranking AS ranking LEFT JOIN sector_radar_ranking AS ranking
ON ranking.publication_id = pool.publication_id ON ranking.publication_id = pool.publication_id
@@ -1016,7 +1049,7 @@ class PostgresSectorRadarRepository:
ranking.metric_value, ranking.quality, ranking.member_count, ranking.metric_value, ranking.quality, ranking.member_count,
ranking.valid_sample_count, ranking.membership_coverage, ranking.valid_sample_count, ranking.membership_coverage,
ranking.moneyflow_coverage, ranking.rank_position, ranking.moneyflow_coverage, ranking.rank_position,
ranking.rank_percentile, ranking.rank_changes ranking.rank_percentile, ranking.rank_changes, ranking.weighted_score
FROM sector_radar_ranking AS ranking FROM sector_radar_ranking AS ranking
JOIN selected ON selected.id = ranking.publication_id JOIN selected ON selected.id = ranking.publication_id
ORDER BY selected.target_trade_date DESC, ranking.sector_type, ORDER BY selected.target_trade_date DESC, ranking.sector_type,
@@ -1168,6 +1201,7 @@ class PostgresSectorRadarRepository:
ranking.rank_position, ranking.rank_position,
ranking.rank_percentile, ranking.rank_percentile,
Jsonb({str(change.days): change.value for change in ranking.rank_changes}), Jsonb({str(change.days): change.value for change in ranking.rank_changes}),
observation.weighted_score,
) )
) )
return tuple(rows) return tuple(rows)
@@ -1258,6 +1292,7 @@ class PostgresSectorRadarRepository:
valid_sample_count=int(row[11]), valid_sample_count=int(row[11]),
membership_coverage=Decimal(str(row[12])), membership_coverage=Decimal(str(row[12])),
moneyflow_coverage=Decimal(str(row[13])), moneyflow_coverage=Decimal(str(row[13])),
weighted_score=None if row[17] is None else Decimal(str(row[17])),
) )
return RankedMetric( return RankedMetric(
observation=observation, observation=observation,
@@ -10,6 +10,7 @@ from datetime import date
from ....bootstrap.config import get_settings from ....bootstrap.config import get_settings
from ..application.build import BuildSectorRadar, BuildSectorRadarCommand from ..application.build import BuildSectorRadar, BuildSectorRadarCommand
from ..application.recompute import RecomputeSectorRadar
from ..infrastructure.postgres import PostgresSectorRadarRepository from ..infrastructure.postgres import PostgresSectorRadarRepository
from ..infrastructure.tushare import TushareSectorRadarAdapter from ..infrastructure.tushare import TushareSectorRadarAdapter
@@ -30,6 +31,11 @@ def build_parser() -> argparse.ArgumentParser:
) )
mode.add_argument("--start-date", type=_parse_date, help="inclusive backfill start date") 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") parser.add_argument("--end-date", type=_parse_date, help="inclusive backfill end date")
parser.add_argument(
"--recompute",
action="store_true",
help="rescore saved aggregates without fetching external sources",
)
return parser return parser
@@ -39,6 +45,8 @@ def main(argv: Sequence[str] | None = None) -> int:
args = build_parser().parse_args(argv) args = build_parser().parse_args(argv)
if (args.start_date is None) != (args.end_date is None): if (args.start_date is None) != (args.end_date is None):
raise SystemExit("--start-date and --end-date must be provided together") raise SystemExit("--start-date and --end-date must be provided together")
if args.recompute and args.retry_publication_id:
raise SystemExit("--recompute cannot be combined with --retry-publication-id")
command = BuildSectorRadarCommand( command = BuildSectorRadarCommand(
trade_date=args.trade_date, trade_date=args.trade_date,
start_date=args.start_date, start_date=args.start_date,
@@ -59,22 +67,25 @@ def main(argv: Sequence[str] | None = None) -> int:
command.end_date or "none", command.end_date or "none",
bool(command.retry_publication_id), 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( repository = PostgresSectorRadarRepository(
settings.database_url, settings.database_url,
advisory_lock_key=settings.sector_radar_advisory_lock_key, advisory_lock_key=settings.sector_radar_advisory_lock_key,
) )
try: try:
summary = BuildSectorRadar( if args.recompute:
source, summary = RecomputeSectorRadar(repository).execute(command)
repository, else:
coverage_threshold=settings.sector_radar_coverage_threshold, source = TushareSectorRadarAdapter.from_token(
).execute(command) 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,
)
summary = BuildSectorRadar(
source,
repository,
coverage_threshold=settings.sector_radar_coverage_threshold,
).execute(command)
finally: finally:
repository.close() repository.close()
except Exception as exc: # noqa: BLE001 - CLI boundary returns a redacted scheduler result except Exception as exc: # noqa: BLE001 - CLI boundary returns a redacted scheduler result
@@ -98,6 +98,7 @@ class RadarRankingRowResponse(BaseModel):
implementation_kind: Literal["independent"] implementation_kind: Literal["independent"]
unit: MetricUnit unit: MetricUnit
metric_value: Decimal | None metric_value: Decimal | None
weighted_score: Decimal | None = None
quality: MetricQuality quality: MetricQuality
member_count: int = Field(ge=0) member_count: int = Field(ge=0)
valid_sample_count: int = Field(ge=0) valid_sample_count: int = Field(ge=0)
@@ -107,6 +108,9 @@ class RadarRankingRowResponse(BaseModel):
rank_percentile: Decimal | None = Field(default=None, gt=0, le=100) rank_percentile: Decimal | None = Field(default=None, gt=0, le=100)
rank_change_days: int = Field(ge=1, le=5) rank_change_days: int = Field(ge=1, le=5)
rank_change: int | None rank_change: int | None
rank_change_values: dict[MetricKind, int | None] = Field(
default_factory=lambda: dict[MetricKind, int | None]()
)
pct_change: Decimal | None = None pct_change: Decimal | None = None
daily_net_amount_yuan: Decimal | None = None daily_net_amount_yuan: Decimal | None = None
daily_ratio: Decimal | None = None daily_ratio: Decimal | None = None
@@ -127,6 +131,7 @@ class RadarHistoryMetricResponse(RadarDetailModel):
rank_percentile: Decimal | None rank_percentile: Decimal | None
pool_size: int pool_size: int
metric_value: Decimal | None metric_value: Decimal | None
weighted_score: Decimal | None = None
missing: bool missing: bool
in_top: bool in_top: bool
in_bottom: bool in_bottom: bool
@@ -222,6 +227,7 @@ class RadarRankingsResponse(BaseModel):
view: RadarView view: RadarView
rank_change_metric: MetricKind rank_change_metric: MetricKind
rank_change_days: int = Field(ge=1, le=5) rank_change_days: int = Field(ge=1, le=5)
comparison_trade_date: date | None = None
side: RankSide side: RankSide
search: str | None search: str | None
publication: RadarPublicationResponse | None publication: RadarPublicationResponse | None
@@ -303,7 +309,7 @@ def get_sector_radar_rankings(
trade_date: date | None = None, trade_date: date | None = None,
sector_type: SectorType = SectorType.CONCEPT, sector_type: SectorType = SectorType.CONCEPT,
view: RadarView = RadarView.AMOUNT, view: RadarView = RadarView.AMOUNT,
rank_change_metric: MetricKind = MetricKind.AMOUNT, rank_change_metric: MetricKind = MetricKind.SWING,
rank_change_days: Annotated[int, Query(ge=1, le=5)] = 1, rank_change_days: Annotated[int, Query(ge=1, le=5)] = 1,
side: RankSide = RankSide.ALL, side: RankSide = RankSide.ALL,
search: Annotated[str | None, Query(max_length=100)] = None, search: Annotated[str | None, Query(max_length=100)] = None,
@@ -458,6 +464,7 @@ def _rankings_response(page: RankingPage) -> RadarRankingsResponse:
view=query.view, view=query.view,
rank_change_metric=query.rank_change_metric, rank_change_metric=query.rank_change_metric,
rank_change_days=query.rank_change_days, rank_change_days=query.rank_change_days,
comparison_trade_date=page.comparison_trade_date,
side=query.side, side=query.side,
search=query.search, search=query.search,
publication=( publication=(
@@ -469,7 +476,10 @@ def _rankings_response(page: RankingPage) -> RadarRankingsResponse:
total=page.total, total=page.total,
rows=[ rows=[
_ranking_response( _ranking_response(
row, query.rank_change_days, page.extras.get(row.observation.sector_code) row,
query.rank_change_days,
page.extras.get(row.observation.sector_code),
page.rank_change_values.get(row.observation.sector_code),
) )
for row in page.rows for row in page.rows
], ],
@@ -525,7 +535,10 @@ def _definition_response(
def _ranking_response( def _ranking_response(
row: RankedMetric, rank_change_days: int, extras: RankingExtras | None = None row: RankedMetric,
rank_change_days: int,
extras: RankingExtras | None = None,
changes: dict[MetricKind, int | None] | None = None,
) -> RadarRankingRowResponse: ) -> RadarRankingRowResponse:
observation = row.observation observation = row.observation
extras = extras or RankingExtras() extras = extras or RankingExtras()
@@ -539,6 +552,7 @@ def _ranking_response(
implementation_kind=observation.implementation_kind, implementation_kind=observation.implementation_kind,
unit=observation.unit, unit=observation.unit,
metric_value=observation.value, metric_value=observation.value,
weighted_score=observation.weighted_score,
quality=observation.quality, quality=observation.quality,
member_count=observation.member_count, member_count=observation.member_count,
valid_sample_count=observation.valid_sample_count, valid_sample_count=observation.valid_sample_count,
@@ -548,6 +562,7 @@ def _ranking_response(
rank_percentile=row.rank_percentile, rank_percentile=row.rank_percentile,
rank_change_days=rank_change_days, rank_change_days=rank_change_days,
rank_change=row.rank_change(rank_change_days), rank_change=row.rank_change(rank_change_days),
rank_change_values={kind: (changes or {}).get(kind) for kind in MetricKind},
pct_change=extras.pct_change, pct_change=extras.pct_change,
daily_net_amount_yuan=extras.daily_net_amount_yuan, daily_net_amount_yuan=extras.daily_net_amount_yuan,
daily_ratio=extras.daily_ratio, daily_ratio=extras.daily_ratio,
@@ -225,6 +225,35 @@ def test_no_data_is_a_stable_200_response() -> None:
assert rankings.json()["rows"] == [] assert rankings.json()["rows"] == []
def test_weighted_score_and_all_rank_changes_cross_the_http_boundary() -> None:
reader = FakeReader()
ranking = _ranking()
reader.page = replace(
reader.page,
rows=(
replace(
ranking,
observation=replace(
ranking.observation, weighted_score=Decimal("712.345678901234")
),
),
),
comparison_trade_date=date(2026, 8, 21),
rank_change_values={
"BK0001.DC": {MetricKind.AMOUNT: 3, MetricKind.RATIO: 0, MetricKind.SWING: None}
},
)
response = _client(reader).get("/api/v1/sector-radar/rankings", params={"view": "rank_change"})
assert response.status_code == 200
assert (
reader.last_query is not None and reader.last_query.rank_change_metric is MetricKind.SWING
)
payload = response.json()
assert payload["comparison_trade_date"] == "2026-08-21"
assert payload["rows"][0]["weighted_score"] == "712.345678901234"
assert payload["rows"][0]["rank_change_values"] == {"amount": 3, "ratio": 0, "swing": None}
def test_http_contract_rejects_zero_rank_percentile() -> None: def test_http_contract_rejects_zero_rank_percentile() -> None:
payload = _client(FakeReader()).get("/api/v1/sector-radar/rankings").json()["rows"][0] payload = _client(FakeReader()).get("/api/v1/sector-radar/rankings").json()["rows"][0]
payload["rank_percentile"] = "0" payload["rank_percentile"] = "0"
@@ -599,10 +599,14 @@ def test_tenth_trading_day_publishes_swing_and_five_rank_changes() -> None:
swing = tuple( swing = tuple(
ranking ranking
for ranking in current for ranking in current
if ranking.observation.metric_version == "zhixing_swing_equal_3_10_v1" if ranking.observation.metric_version == "zhixing_swing_weighted_v2"
) )
assert len(swing) == 2 assert len(swing) == 2
assert all(ranking.observation.value == Decimal("0.03") for ranking in swing) assert all(
ranking.observation.value == pytest.approx(Decimal(150000) / Decimal(5000100))
for ranking in swing
)
assert all(ranking.observation.weighted_score is not None for ranking in swing)
assert all( assert all(
tuple(change.value for change in ranking.rank_changes) == (None, None, None, None, None) tuple(change.value for change in ranking.rank_changes) == (None, None, None, None, None)
for ranking in swing for ranking in swing
@@ -786,7 +790,7 @@ def test_detail_history_and_ranking_extras_http_use_the_same_publication() -> No
row = ranking.json()["rows"][0] row = ranking.json()["rows"][0]
assert row["pct_change"] == "1" assert row["pct_change"] == "1"
assert Decimal(row["daily_net_amount_yuan"]) == 150000 assert Decimal(row["daily_net_amount_yuan"]) == 150000
assert Decimal(row["daily_ratio"]) == Decimal("0.03") assert Decimal(row["daily_ratio"]) == Decimal(150000) / Decimal(5000100)
assert row["on_list_count"] == row["history_available_days"] == 1 assert row["on_list_count"] == row["history_available_days"] == 1
assert client.get(base + "/detail").status_code == 422 assert client.get(base + "/detail").status_code == 422
absent = client.get(base + "/detail", params={"trade_date": "2020-01-01"}) absent = client.get(base + "/detail", params={"trade_date": "2020-01-01"})
@@ -805,6 +809,13 @@ def test_postgres_detail_migration_and_build_roundtrip(monkeypatch: pytest.Monke
from zhixing_server.bootstrap.config import sqlalchemy_database_url from zhixing_server.bootstrap.config import sqlalchemy_database_url
from zhixing_server.modules.sector_radar.application.details import ReadRadarDetails from zhixing_server.modules.sector_radar.application.details import ReadRadarDetails
from zhixing_server.modules.sector_radar.application.recompute import RecomputeSectorRadar
from zhixing_server.modules.sector_radar.domain.metrics import (
AmountNetStrategy,
RatioTurnoverStrategy,
SwingEqualThreeToTenStrategy,
)
from zhixing_server.modules.sector_radar.domain.models import MetricKind
from zhixing_server.modules.sector_radar.infrastructure.postgres import ( from zhixing_server.modules.sector_radar.infrastructure.postgres import (
PostgresSectorRadarRepository, PostgresSectorRadarRepository,
) )
@@ -843,7 +854,7 @@ def test_postgres_detail_migration_and_build_roundtrip(monkeypatch: pytest.Monke
assert detail.summary["amount"].metric_value == Decimal("0.0015") assert detail.summary["amount"].metric_value == Decimal("0.0015")
with psycopg.connect(database_url) as connection: with psycopg.connect(database_url) as connection:
assert connection.execute("SELECT version_num FROM alembic_version").fetchone() == ( assert connection.execute("SELECT version_num FROM alembic_version").fetchone() == (
"0009_radar_sector_detail", "0010_radar_weighted_score",
) )
row = connection.execute( row = connection.execute(
"SELECT pct_change, leading_code FROM sector_radar_daily_aggregate " "SELECT pct_change, leading_code FROM sector_radar_daily_aggregate "
@@ -863,5 +874,140 @@ def test_postgres_detail_migration_and_build_roundtrip(monkeypatch: pytest.Monke
"SET active_buy_net_amount_yuan = 'NaN'::numeric WHERE trade_date = %s", "SET active_buy_net_amount_yuan = 'NaN'::numeric WHERE trade_date = %s",
(target,), (target,),
) )
# Exercise every ranking projection against persisted v2 scores, while
# keeping the original source publication and its NULL score readable.
start, end = target + timedelta(days=31), target + timedelta(days=46)
interval = BuildSectorRadarCommand(start_date=start, end_date=end)
source = FakeRadarSource()
legacy = BuildSectorRadar(
source,
repository,
now_fn=lambda: NOW,
strategies=(
AmountNetStrategy(),
RatioTurnoverStrategy(),
SwingEqualThreeToTenStrategy(),
),
).execute(interval)
assert legacy.status in ("success", "unchanged")
old_id = legacy.outcomes[-1].publication_id
assert old_id is not None
old_rows = tuple(repository.load_rankings(old_id))
assert all(row.observation.weighted_score is None for row in old_rows)
source.calls.clear()
rescore = RecomputeSectorRadar(repository, now_fn=lambda: NOW + timedelta(hours=1))
updated = rescore.execute(interval)
assert updated.status in ("success", "unchanged")
updated_id = updated.outcomes[-1].publication_id
assert updated_id is not None and updated_id != old_id
assert source.calls == []
assert tuple(repository.load_rankings(old_id)) == old_rows
current_rows = tuple(repository.load_rankings(updated_id))
scored = next(
row
for row in current_rows
if row.observation.metric_kind is MetricKind.SWING
and row.observation.sector_type is SectorType.CONCEPT
)
expected = ((Decimal(5000000) + 1).log10() * 100).quantize(Decimal("0.000000000001"))
assert scored.observation.weighted_score == expected
assert scored.rank_change(5) == 0
assert dict(repository.load_publication_rankings((updated_id,)))[updated_id] == current_rows
previous = dict(repository.load_previous_rankings(end + timedelta(days=1), limit_dates=1))
assert previous[end] == current_rows
historical = repository.load_ranked_history((updated_id,), ("BK0001.DC",))
assert any(row.ranking == scored for row in historical)
detail = ReadRadarDetails(repository).detail(end, SectorType.CONCEPT, "BK0001.DC")
assert detail.summary["swing"].weighted_score == expected
assert detail.pct_change == 1 and len(detail.members) == 5
assert rescore.execute(interval).status == "unchanged"
with (
psycopg.connect(database_url) as connection,
pytest.raises(psycopg.errors.CheckViolation),
connection.transaction(),
):
connection.execute(
"UPDATE sector_radar_ranking SET weighted_score = 'NaN'::numeric "
"WHERE publication_id = %s",
(updated_id,),
)
finally: finally:
repository.close() repository.close()
def test_offline_rescore_is_idempotent_preserves_old_publications_and_details() -> None:
from zhixing_server.modules.sector_radar.application.details import ReadRadarDetails
from zhixing_server.modules.sector_radar.application.read import (
RadarQuery,
RadarView,
ReadSectorRadar,
)
from zhixing_server.modules.sector_radar.application.recompute import RecomputeSectorRadar
from zhixing_server.modules.sector_radar.domain.metrics import (
AmountNetStrategy,
RatioTurnoverStrategy,
SwingEqualThreeToTenStrategy,
)
source = FakeRadarSource()
repository = InMemorySectorRadarRepository()
end = TARGET_DATE + timedelta(days=15)
command = BuildSectorRadarCommand(start_date=TARGET_DATE, end_date=end)
initial = BuildSectorRadar(
source,
repository,
now_fn=lambda: NOW,
strategies=(AmountNetStrategy(), RatioTurnoverStrategy(), SwingEqualThreeToTenStrategy()),
).execute(command)
assert initial.status == "success"
old_publications = repository.publications.copy()
old_rankings = repository.rankings.copy()
snapshot_count = len(repository.source_snapshots)
source.calls.clear()
source.fail_daily = True
service = RecomputeSectorRadar(repository, now_fn=lambda: NOW + timedelta(hours=1))
result = service.execute(command)
assert result.status == "success"
assert all(repository.publications[key] == value for key, value in old_publications.items())
assert all(repository.rankings[key] == value for key, value in old_rankings.items())
assert len(repository.source_snapshots) == snapshot_count
assert source.calls == []
latest = repository.get_last_good_publication(end)
assert latest is not None
assert "zhixing_swing_weighted_v2" in latest.metric_versions
count = len(repository.publications)
assert service.execute(command).status == "unchanged"
assert len(repository.publications) == count
reader = ReadSectorRadar(repository)
page = reader.query(RadarQuery(trade_date=end, view=RadarView.SWING))
assert len(page.rows) == 1
assert page.rows[0].observation.weighted_score is not None
assert page.rows[0].rank_change(5) == 0
detail = ReadRadarDetails(repository).detail(end, SectorType.CONCEPT, "BK0001.DC")
assert len(detail.members) == 5
assert detail.pct_change == 1
assert detail.summary["swing"].weighted_score == page.rows[0].observation.weighted_score
assert detail.summary["swing"].rank_position == page.rows[0].rank_position
def test_offline_rescore_failure_does_not_replace_last_good(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from unittest.mock import Mock
from zhixing_server.modules.sector_radar.application.recompute import RecomputeSectorRadar
repository = InMemorySectorRadarRepository()
command = BuildSectorRadarCommand(trade_date=TARGET_DATE)
BuildSectorRadar(FakeRadarSource(), repository, now_fn=lambda: NOW).execute(command)
before = repository.get_last_good_publication()
monkeypatch.setattr(
repository, "finalize_publication", Mock(side_effect=RuntimeError("injected failure"))
)
result = RecomputeSectorRadar(repository, now_fn=lambda: NOW + timedelta(hours=1)).execute(
command
)
assert result.status == "failed"
assert repository.get_last_good_publication() == before
latest = repository.get_latest_publication()
assert latest is not None and latest.status is PublicationStatus.FAILED
@@ -61,6 +61,7 @@ class FakeConnection:
1, 1,
Decimal(100), Decimal(100),
{"1": 3, "2": None}, {"1": 3, "2": None},
None,
), ),
) )
) )
@@ -161,6 +162,7 @@ def test_load_rankings_reconstructs_values_and_rank_changes() -> None:
ranking = rankings[0] ranking = rankings[0]
assert ranking.observation.metric_version == "zhixing_amount_net_bn_v1" assert ranking.observation.metric_version == "zhixing_amount_net_bn_v1"
assert ranking.observation.value == Decimal("12.5") assert ranking.observation.value == Decimal("12.5")
assert ranking.observation.weighted_score is None
assert ranking.rank_position == 1 assert ranking.rank_position == 1
assert ranking.rank_change(1) == 3 assert ranking.rank_change(1) == 3
assert ranking.rank_change(2) is None assert ranking.rank_change(2) is None
@@ -158,7 +158,45 @@ def test_percentile_side_is_selected_before_search_and_pagination() -> None:
def test_rank_change_uses_selected_metric_days_and_pool_sides() -> None: def test_rank_change_uses_selected_metric_days_and_pool_sides() -> None:
reader = ReadSectorRadar(_published_repository()) from zhixing_server.modules.sector_radar.domain.persistence import (
PublicationSourceGroup,
PublicationSourceRecord,
)
from zhixing_server.modules.sector_radar.domain.source import build_source_snapshot
repository = _published_repository()
dates = [date(2026, 8, day) for day in [21, 24, 25, 26, 27, 28]]
calendar = build_source_snapshot(
api_name="trade_cal",
params={},
observed_at=NOW,
target_trade_date=TARGET_DATE,
rows=tuple({"exchange": "SSE", "cal_date": day.isoformat(), "is_open": 1} for day in dates),
)
repository.save_source_snapshots((calendar,))
repository.save_publication_sources(
(
PublicationSourceRecord(
"publication-success", PublicationSourceGroup.CALENDAR, 0, calendar
),
)
)
old = _running("past-publication", dates[0])
repository.create_publication(old)
past = tuple(
replace(
row.observation,
trade_date=dates[0],
value=Decimal(index),
sector_code="BK9999.DC" if index == 5 else row.observation.sector_code,
)
for index, row in enumerate(_amount_rankings(), 1)
)
repository.save_rankings(
RankingRecord(old.publication_id, row) for row in rank_metric_observations(past)
)
repository.finish_publication(_finish(old, PublicationStatus.SUCCESS))
reader = ReadSectorRadar(repository)
query = RadarQuery( query = RadarQuery(
view=RadarView.RANK_CHANGE, view=RadarView.RANK_CHANGE,
rank_change_metric=MetricKind.AMOUNT, rank_change_metric=MetricKind.AMOUNT,
@@ -170,12 +208,14 @@ def test_rank_change_uses_selected_metric_days_and_pool_sides() -> None:
all_rows = reader.query(query) all_rows = reader.query(query)
assert top.total == 1 assert top.total == 1
assert top.rows[0].rank_change(5) == 5 assert top.rows[0].rank_change(5) == 9
assert bottom.total == 1 assert bottom.total == 1
assert bottom.rows[0].rank_change(5) == -4 assert bottom.rows[0].rank_change(5) == -9
assert all_rows.total == 10 assert all_rows.total == 10
assert all_rows.rows[-1].observation.sector_code == "BK0005.DC" assert all_rows.rows[-1].observation.sector_code == "BK0005.DC"
assert all_rows.rows[-1].rank_change(5) is None assert all_rows.rows[-1].rank_change(5) is None
assert top.comparison_trade_date == dates[0]
assert top.rank_change_values["BK0001.DC"][MetricKind.AMOUNT] == 9
def test_latest_partial_attempt_is_visible_but_does_not_replace_last_good() -> None: def test_latest_partial_attempt_is_visible_but_does_not_replace_last_good() -> None:
@@ -0,0 +1,160 @@
from dataclasses import replace
from datetime import date, timedelta
from decimal import Decimal
import pytest
from zhixing_server.modules.sector_radar.application.scoring import (
calculate_rankings,
calendar_rank_changes,
comparison_dates,
)
from zhixing_server.modules.sector_radar.domain.models import (
MetricKind,
RankedMetric,
RankSide,
SectorDailyAggregate,
SectorType,
)
from zhixing_server.modules.sector_radar.domain.ranking import select_percentile_side
from zhixing_server.modules.sector_radar.domain.weighted import (
RATIO_WEIGHTED_VERSION,
SWING_WEIGHTED_VERSION,
resolve_metric_version,
)
DAYS = tuple(
date(2026, 9, 1) + timedelta(days=n)
for n in range(21)
if (date(2026, 9, 1) + timedelta(days=n)).weekday() < 5
)
def aggregate(
code: str,
day: date,
ratio: str,
turnover: str = "9999999999",
kind: SectorType = SectorType.CONCEPT,
) -> SectorDailyAggregate:
amount = Decimal(turnover)
return SectorDailyAggregate(
day,
kind,
code,
code,
10,
10,
Decimal(ratio) * (amount + 100),
amount,
Decimal(1),
Decimal(1),
)
def scores(
rows: list[SectorDailyAggregate], target: date = DAYS[9]
) -> dict[tuple[SectorType, str, MetricKind], RankedMetric]:
return {
(row.observation.sector_type, row.observation.sector_code, row.observation.metric_kind): row
for row in calculate_rankings(
target,
[item for item in rows if item.trade_date == target],
[item for item in rows if item.trade_date != target],
DAYS,
)
}
def test_liquidity_weight_can_reverse_raw_ratio_order_and_separates_pools() -> None:
rows = [
aggregate(code, day, ratio, turnover, kind)
for day in DAYS[:10]
for code, ratio, turnover, kind in [
("small", ".3", "999999", SectorType.CONCEPT),
("large", ".2", "999999999999", SectorType.CONCEPT),
("medium", ".1", "9999999999", SectorType.CONCEPT),
("industry", "-.5", "9999999999", SectorType.INDUSTRY),
]
]
ranked = scores(rows)
small = ranked[(SectorType.CONCEPT, "small", MetricKind.RATIO)]
large = ranked[(SectorType.CONCEPT, "large", MetricKind.RATIO)]
assert small.observation.value == Decimal(".3")
assert small.observation.weighted_score == 600
assert large.observation.weighted_score == 800
assert large.rank_position == 1 and small.rank_position == 2
assert (
ranked[(SectorType.INDUSTRY, "industry", MetricKind.RATIO)].observation.weighted_score
== 1000
)
def test_swing_ranks_each_window_before_combining_not_the_averaged_raw_ratio() -> None:
rows = [
aggregate(code, day, ratio)
for index, day in enumerate(DAYS[:10])
for code, ratio in [("A", ".1" if index >= 7 else "-.1"), ("B", ".03"), ("C", ".05")]
]
ranked = scores(rows)
a = ranked[(SectorType.CONCEPT, "A", MetricKind.SWING)]
b = ranked[(SectorType.CONCEPT, "B", MetricKind.SWING)]
assert a.observation.value == b.observation.value == Decimal(".03")
assert a.observation.weighted_score == pytest.approx(Decimal("666.6666666666667"))
assert b.observation.weighted_score == 500
assert a.rank_position == 2 and b.rank_position == 3
def test_tied_features_use_average_percentiles_and_code_breaks_final_ties() -> None:
ranked = scores([aggregate(code, day, "0") for day in DAYS[:10] for code in ["B", "A"]])
assert ranked[(SectorType.CONCEPT, "A", MetricKind.RATIO)].observation.weighted_score == 750
assert ranked[(SectorType.CONCEPT, "B", MetricKind.RATIO)].observation.weighted_score == 750
assert ranked[(SectorType.CONCEPT, "A", MetricKind.RATIO)].rank_position == 1
def test_missing_calendar_session_is_not_replaced_by_an_older_success() -> None:
rows = [aggregate("A", day, ".2") for day in DAYS[:10] if day != DAYS[7]]
ranked = scores(rows)
daily = ranked[(SectorType.CONCEPT, "A", MetricKind.RATIO)]
assert daily.observation.value == Decimal(".2")
assert daily.observation.weighted_score is None and daily.rank_position is None
assert ranked[(SectorType.CONCEPT, "A", MetricKind.SWING)].observation.value is None
def test_future_inputs_do_not_affect_scores_and_bottom_uses_score_order() -> None:
rows = [
aggregate(f"C{n}", day, str(n), str(10 ** (6 + n) - 1))
for day in DAYS[:10]
for n in range(1, 11)
]
original = scores(rows)
future = scores(rows + [aggregate("C1", DAYS[10], "1000000")])
assert future == original
ratio_rows = [
row for row in original.values() if row.observation.metric_kind is MetricKind.RATIO
]
bottom = select_percentile_side(ratio_rows, RankSide.BOTTOM)
assert [row.observation.sector_code for row in bottom] == ["C1"]
def test_rank_changes_resolve_trading_dates_and_do_not_mix_versions() -> None:
rows = [aggregate("A", day, ".1") for day in DAYS[:11]]
before = tuple(scores(rows, DAYS[9]).values())
current = tuple(scores(rows, DAYS[10]).values())
assert comparison_dates(DAYS, DAYS[10])[1] == DAYS[9]
missing = calendar_rank_changes(current, {DAYS[8]: before}, DAYS, DAYS[10])
assert all(row.rank_change(1) is None for row in missing)
older = tuple(
replace(row, observation=replace(row.observation, metric_version="legacy"))
for row in before
)
incompatible = calendar_rank_changes(current, {DAYS[9]: older}, DAYS, DAYS[10])
assert all(row.rank_change(1) is None for row in incompatible)
comparable = calendar_rank_changes(current, {DAYS[9]: before}, DAYS, DAYS[10])
assert all(row.rank_change(1) == 0 for row in comparable)
assert (
resolve_metric_version(MetricKind.RATIO, [RATIO_WEIGHTED_VERSION]) == RATIO_WEIGHTED_VERSION
)
assert (
resolve_metric_version(MetricKind.SWING, [SWING_WEIGHTED_VERSION]) == SWING_WEIGHTED_VERSION
)
+378 -378
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -3,3 +3,4 @@ dist
node_modules node_modules
pnpm-lock.yaml pnpm-lock.yaml
.pnpm-store .pnpm-store
.playwright-cli
@@ -8,6 +8,7 @@ import {
getSectorRadarHistory, getSectorRadarHistory,
getSectorRadarDetail, getSectorRadarDetail,
parseRadarDetailResponse, parseRadarDetailResponse,
parseRadarRankingsResponse,
getSectorRadarDates, getSectorRadarDates,
getSectorRadarRankings, getSectorRadarRankings,
getStockSectorMembership, getStockSectorMembership,
@@ -133,6 +134,72 @@ describe("sector radar API adapters", () => {
requestJson.mockReset() requestJson.mockReset()
}) })
it("preserves score precision, signed changes, zero and unknown historical values", () => {
const response = parseRadarRankingsResponse({
...rankingPayload,
comparison_trade_date: "2026-08-21",
rows: [
{
...rankingPayload.rows[0],
weighted_score: "712.345678901234",
rank_change_values: { amount: 3, ratio: 0, swing: null },
},
],
})
expect(response.comparison_trade_date).toBe("2026-08-21")
expect(response.rows[0]?.weighted_score).toBe(712.345678901234)
expect(response.rows[0]?.rank_change_values).toEqual({
amount: 3,
ratio: 0,
swing: null,
})
const legacy = parseRadarRankingsResponse(rankingPayload)
expect(legacy.rows[0]?.weighted_score).toBeNull()
expect(legacy.rows[0]?.rank_change_values).toEqual({
amount: null,
ratio: null,
swing: null,
})
expect(legacy.comparison_trade_date).toBeNull()
const detail = parseRadarDetailResponse({
...detailPayload,
summary: {
...detailPayload.summary,
ratio: { ...historyMetric, weighted_score: "712.345" },
},
})
expect(detail.summary.ratio.weighted_score).toBe(712.345)
})
it.each(["NaN", "Infinity", "0x10", "", true])(
"rejects malformed weighted scores %s",
(weighted_score) => {
expect(() =>
parseRadarRankingsResponse({
...rankingPayload,
rows: [{ ...rankingPayload.rows[0], weighted_score }],
}),
).toThrow("weighted_score")
},
)
it("rejects fractional changes and invalid comparison dates", () => {
expect(() =>
parseRadarRankingsResponse({
...rankingPayload,
rows: [
{ ...rankingPayload.rows[0], rank_change_values: { amount: 0.5 } },
],
}),
).toThrow("rank_change_values.amount")
expect(() =>
parseRadarRankingsResponse({
...rankingPayload,
comparison_trade_date: "yesterday",
}),
).toThrow("comparison_trade_date")
})
it("reads persisted sector resources and preserves nullable independent metrics", async () => { it("reads persisted sector resources and preserves nullable independent metrics", async () => {
const signal = new AbortController().signal const signal = new AbortController().signal
const query = { const query = {

Some files were not shown because too many files have changed in this diff Show More