Merge pull request 'Develop' (#23) from develop into main
Deploy Production / deploy (push) Successful in 27s
Deploy Production / deploy (push) Successful in 27s
Reviewed-on: sakibcc/zhixing-system#23
This commit was merged in pull request #23.
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "前端质量门禁与测试规范"}
|
||||||
|
{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "组件可访问性与列表交互规范"}
|
||||||
|
{"file": ".trellis/tasks/09-01-optimize-stock-list-radar/prd.md", "reason": "用户需求与验收标准"}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 技术设计
|
||||||
|
|
||||||
|
## 边界与组件结构
|
||||||
|
|
||||||
|
选股模块保留 `SelectionResultsWorkbench` 的筛选、分页、选中状态和详情面板结构,将当前桌面 `SignalTable` 与移动 `SignalRecordList` 的双实现收敛为一套响应式紧凑卡片列表。列表继续位于左侧结果区内部滚动;条目用语义化 `article` 和真实按钮承载选择、展开交互,不改变路由搜索参数或 API 类型。
|
||||||
|
|
||||||
|
资金雷达继续使用语义化表格与现有无限滚动容器,只删除展示层中的资金覆盖率和质量表头/单元格,并同步空状态 `colSpan`。通过缩小表头和数据行的垂直 padding、减少板块名称与代码间距来提高密度,不改动返回数据类型或质量计算。
|
||||||
|
|
||||||
|
## 数据流与兼容性
|
||||||
|
|
||||||
|
两处改动均消费现有响应字段,不改变 query、路由和后端契约。选股卡片仍用 `getStockKey`、`getStockJValue`、`PatternScoreSummary`、`SignalDetails` 和现有 category 样式函数,确保内容与详情行为保持一致。资金雷达保留 `quality` 和 `moneyflow_coverage` 在 TypeScript 响应类型中,仅不展示,以避免形成不必要的接口破坏。
|
||||||
|
|
||||||
|
## 取舍
|
||||||
|
|
||||||
|
选股列表采用单一响应式组件,消除桌面/移动两套 DOM 和测试分叉;卡片通过横向信息区和较小间距兼顾密度与窄屏换行。资金雷达保留 table,因为用户仅要求缩减列和行高,表格仍最适合对齐排名数据。
|
||||||
|
|
||||||
|
## 回滚
|
||||||
|
|
||||||
|
改动局限于选股展示组件、工作台装配、资金雷达页面及对应测试;如出现视觉或交互回归,可逐文件回退,不涉及数据迁移。
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{"file": ".trellis/spec/frontend/index.md", "reason": "前端开发入口与检查清单"}
|
||||||
|
{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "组件、样式与可访问性规范"}
|
||||||
|
{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "测试与质量门禁,约束卡片列表与雷达表格的断言方式"}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# 实施计划
|
||||||
|
|
||||||
|
1. 将选股结果收敛为单一紧凑卡片列表,保留选中、键盘、展开详情、空状态与内部滚动。
|
||||||
|
2. 更新工作台装配,移除 table 展示路径和重复组件引用,并按需要删除不再使用的文件。
|
||||||
|
3. 更新选股页面测试,改为断言卡片列表结构、核心字段和交互,不再依赖 table 表头。
|
||||||
|
4. 从资金雷达表格移除资金覆盖率与质量两列,修正 `colSpan` 并缩小表头、数据行间距。
|
||||||
|
5. 更新资金雷达测试,明确断言精简列不存在而保留百分位和样本;保留无限滚动与排名变化回归覆盖。
|
||||||
|
6. 在 `zhixing-web/` 运行目标 Vitest,然后运行 `pnpm format:check`、`pnpm lint`、`pnpm typecheck`、`pnpm test`;必要时运行 `pnpm build` 验证产物。
|
||||||
|
|
||||||
|
风险点是选股卡片按钮嵌套和键盘行为、断点下滚动高度链,以及资金雷达 rank-change 模式的动态列数。实现时以语义结构和现有页面布局约束为准,出现回归时优先回滚对应组件的局部改动。
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# 优化选股列表与资金雷达看板布局
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
提升选股结果和资金雷达的浏览密度与可读性,让用户在同一屏看到更多条目,并减少不必要的辅助指标占用。
|
||||||
|
|
||||||
|
## Background and confirmed facts
|
||||||
|
|
||||||
|
当前选股结果在桌面端由 `features/selection/components/signal-table.tsx` 以 `<table>` 展示,移动端另有 `signal-record-list.tsx` 卡片列表;工作台同时挂载两者并通过断点切换。资金雷达页面的 `RadarTable` 表头和行包含“排名百分位、样本、资金覆盖率、质量”等列,行使用较大的垂直 padding。
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
1. 选股结果在所有屏幕尺寸统一使用可滚动列表,不再渲染 table。每个股票条目以紧凑卡片展示股票名称/代码、信号标签、图形评分、J 值和收盘价,并保留点击选中、键盘可操作和详情展开能力。列表容器应继续支持内部滚动和空结果提示,分页与筛选行为保持不变。
|
||||||
|
2. 资金雷达看板保留核心排名、板块、指标值、排名百分位和样本信息,移除“资金覆盖率”和“质量”两列及其单元格内容;表头和数据行改为更紧凑的垂直间距,在不改变滚动加载、错误/空数据状态和排名变化视图的前提下提升可见行数。
|
||||||
|
3. 现有筛选、排序、加载更多和选中股票详情的数据契约不变;仅调整展示层结构和样式。
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] 选股工作台 DOM 中不再出现 `role="table"`/`<table>`,桌面和移动宽度均可看到滚动卡片列表;每张卡片展示股票名称、代码、至少一个信号标签(有信号时)、评分、J 值和收盘价。
|
||||||
|
- [ ] 选股卡片点击或键盘 Enter/Space 可选中股票,详情面板仍显示当前选中项;“查看详情/收起详情”可展开信号细节。
|
||||||
|
- [ ] 资金雷达表头不再包含“资金覆盖率”和“质量”,对应行内容也不再渲染;“排名百分位”和“样本”仍可见。
|
||||||
|
- [ ] 资金雷达数据行垂直间距小于当前实现,滚动到底部仍能触发一次加载更多,加载中和失败重试提示保持可见。
|
||||||
|
- [ ] `pnpm format:check`、`pnpm lint`、`pnpm typecheck`、`pnpm test` 在 `zhixing-web/` 下通过。
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
不调整后端接口、指标计算、筛选/排序语义、分页参数、详情面板字段或全局主题 token。
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"id": "optimize-stock-list-radar",
|
||||||
|
"name": "optimize-stock-list-radar",
|
||||||
|
"title": "优化选股列表与资金雷达看板布局",
|
||||||
|
"description": "",
|
||||||
|
"status": "in_progress",
|
||||||
|
"dev_type": null,
|
||||||
|
"scope": null,
|
||||||
|
"package": null,
|
||||||
|
"priority": "P2",
|
||||||
|
"creator": "codex",
|
||||||
|
"assignee": "codex",
|
||||||
|
"createdAt": "2026-09-01",
|
||||||
|
"completedAt": null,
|
||||||
|
"branch": null,
|
||||||
|
"base_branch": "main",
|
||||||
|
"worktree_path": null,
|
||||||
|
"commit": null,
|
||||||
|
"pr_url": null,
|
||||||
|
"subtasks": [],
|
||||||
|
"children": [],
|
||||||
|
"parent": null,
|
||||||
|
"relatedFiles": [],
|
||||||
|
"notes": "",
|
||||||
|
"meta": {}
|
||||||
|
}
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
# OneChartLab Radar 三个指标补充核验(2026-09-01)
|
||||||
|
|
||||||
|
## 当前版本
|
||||||
|
|
||||||
|
- 首页:<https://onechartlab.com/>
|
||||||
|
- Manifest:<https://onechartlab.com/radar_manifest.json>
|
||||||
|
- 观测到 `LATEST_DATE=2026-08-31`、31 个交易日、最新日期分片 `radar_data/dates/2026-08-31.c094ac3098c9.json`。
|
||||||
|
- 最新分片 791 条:概念 414、行业 377;每条 35 个字段,含 `pct_change`、`Swing_Ratio_Val`、`Swing_Amount_Val`、`Swing_Score`、`Ratio_Raw_Pct`、`Ratio_Score`、`Amount_Raw_BN`、`Amount_Score` 及各类排名字段。
|
||||||
|
|
||||||
|
## 页面证据
|
||||||
|
|
||||||
|
当前首页内联 Vue 模板(本次保存为 `/tmp/onechartlab-index-20260901.html`)显示:
|
||||||
|
|
||||||
|
- `pct_change` 直接用于左右榜涨跌幅展示和表头排序,格式为带符号两位小数百分比;非有限值显示 `-`。
|
||||||
|
- `Swing`/`Ratio` 模式的加权评分分别读取 `Swing_Score`/`Ratio_Score`,保留 1 位小数;tooltip 是“后端特征算法算出的综合加权值”。前端没有计算 score 的代码。
|
||||||
|
- `Swing` 模式的波段流入率读取 `Swing_Ratio_Val * 100`,保留两位小数百分比;`Ratio` 模式读取 `Ratio_Raw_Pct * 100`。
|
||||||
|
- 普通榜候选先按 `type` 分池,再按对应 `RankPct >= 90` 或 `<= 10` 取前后 10%;`Swing` 和 `Ratio` 默认按各自 `*_Score` 排序,`Amount` 默认按 `Amount_Raw_BN` 排序。`Amount_Score` 存在但不参与 `Amount` 榜默认排序。
|
||||||
|
|
||||||
|
## 数值关系
|
||||||
|
|
||||||
|
2026-08-31 的概念样例:
|
||||||
|
|
||||||
|
| 板块 | `pct_change` | `Swing_Ratio_Val` | `Swing_Score` | `Ratio_Raw_Pct` | `Ratio_Score` | `Amount_Raw_BN` |
|
||||||
|
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||||
|
| 知识产权 | 4.03 | 0.0320857 | 985.0391 | 0.1494700 | 988.6650 | 25.0232 |
|
||||||
|
| 盲盒经济 | 4.04 | 0.0465369 | 899.3116 | 0.2279457 | 952.2123 | 16.0316 |
|
||||||
|
| 元宇宙概念 | 2.83 | 0.0152171 | 976.3810 | 0.0695193 | 1032.4349 | 61.7966 |
|
||||||
|
|
||||||
|
概念与行业两个排名池中,`Swing_RankPos` 与 `Swing_Score`、`Ratio_RankPos` 与 `Ratio_Score`、`Amount_RankPos` 与 `Amount_Raw_BN` 均零不匹配。该结果只能确认排序键,不能推出 score 公式。
|
||||||
|
|
||||||
|
横截面统计(Pearson 相关系数):`pct_change` 与 `Ratio_Score` 在概念/行业分别约为 0.714/0.584,与 `Swing_Score` 约为 0.392/0.259;对应 raw 值与 score 的相关性更高但仍非恒等关系。因此涨跌幅可能是后端特征之一,也可能只是与资金强弱共同受行情驱动,不能据此声明“score=涨跌幅+资金”的公式。
|
||||||
|
|
||||||
|
将连续日期的 `Ratio_Raw_Pct` 与当前 `Swing_Ratio_Val` 对齐,在可匹配样本上,最近 3 日简单均值是最相近候选(概念/行业 `R²` 约 0.886/0.882),但拟合存在斜率和截距,4—10 日候选更弱,故不能确认波段值就是 3 日平均。
|
||||||
|
|
||||||
|
## 证据边界
|
||||||
|
|
||||||
|
- 作者视频 `01:36—01:58`:单日流入率概念上是主力流入相对当天成交额的比例,并警告小样本、低活跃度和低成交量失真。
|
||||||
|
- 作者视频 `01:58—02:23`:波段流入率是 3—10 日加权的单日流入率并排名;靠前代表资金留存比例较高,后续延续性只是可能更好。
|
||||||
|
- 页面成分 manifest:`pct_change` 来源 `tushare.daily.pct_chg`,主力净额来源 `tushare.moneyflow_dc.net_amount`,聚合为 `sum`,多日涨跌幅使用 `compound_return`。
|
||||||
|
- 公开日期分片没有逐股成交额、成员快照和资金流明细,故无法从当前公开 payload 直接重算 `Ratio_Raw_Pct`、`Swing_Ratio_Val` 或任一 score。
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# OneChartLab「板块资金雷达」三个指标逻辑补充研究
|
||||||
|
|
||||||
|
> 调研日期:2026-09-01。对象:截图所示“涨跌幅、加权评分、波段流入率”。本文是对既有板块资金雷达报告的补充,不把后端未公开算法包装成可复现事实。
|
||||||
|
|
||||||
|
## 结论先行
|
||||||
|
|
||||||
|
这三个指标处在不同计算层,不能用一个简单公式互相替代:
|
||||||
|
|
||||||
|
| 指标 | 页面直接展示的字段 | 已确认逻辑 | 当前不可确认的部分 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 涨跌幅 | `pct_change` | 直接格式化为带符号的两位小数百分比;可在榜内单独排序 | 板块层如何由成员股 `daily.pct_chg` 聚合、是否等权/成交额加权、停牌和缺失样本处理 |
|
||||||
|
| 加权评分 | `Swing_Score` 或 `Ratio_Score` | 后端预计算的综合加权值;Swing/Ratio 榜按 score 排名 | 特征列表、权重、标准化、截尾、缺失值规则;是否使用涨跌幅无法证明 |
|
||||||
|
| 波段流入率 | `Swing_Ratio_Val` | 多日资金流入率特征,页面显示时乘 100;作者称为 3—10 日加权单日流入率 | 具体日窗口权重、流入率分母、先聚合后求比还是逐股求比、低流动性过滤和缺失规则 |
|
||||||
|
|
||||||
|
因此第一阶段用 Tushare 直接复刻失败并不意外:Tushare 能提供底层行情和资金流事实,但页面只公开结果字段和少量来源契约,没有公开 score 与波段指标的构建端参数。
|
||||||
|
|
||||||
|
## 截图对应的页面结构
|
||||||
|
|
||||||
|
截图顶部选择的是“波段流入率”视角,页面把板块按类型排名池切成左右两榜:左侧“资金进攻榜 TOP 10%”,右侧“资金撤离榜 BOTTOM 10%”。每一行的主要列为:板块/排名、涨跌幅、加权评分、净额、在榜;中间镜像条显示波段流入与流出;右侧对应显示在榜、净额、加权评分、涨跌幅和板块名称。
|
||||||
|
|
||||||
|
这意味着同一行里的数值不是一组可互相换算的字段。以截图中“知识产权”为例,`+4.03%` 是 `pct_change`,`985.0` 是 `Swing_Score` 的一位小数展示,`+3.21%` 是 `Swing_Ratio_Val × 100`,`25.0 亿` 是金额字段。右侧“资金撤离榜”的 `-5.60%` 同样是波段流入率的负向展示,而不是涨跌幅列。
|
||||||
|
|
||||||
|
页面源码证据:<https://onechartlab.com/> 首页内联 Vue 模板中,`pct_change` 的格式化与排序位于当前 HTML 的 `731—744`、`836`、`963` 附近;score tooltip 和字段读取位于 `801—806`、`930—932`;波段流入率格式化位于 `809—810`、`936—937`。行号随页面发布版本变化,应以字段和原文为准。
|
||||||
|
|
||||||
|
## 一、涨跌幅 `pct_change`
|
||||||
|
|
||||||
|
### 页面行为
|
||||||
|
|
||||||
|
无论当前视角是 `Swing`、`Ratio` 还是 `Amount`,表格都会读取同一条板块记录的 `pct_change`,以 `+4.03%`、`-0.15%` 形式显示,保留两位小数;非有限值显示 `-`。点击“涨跌幅”表头只改变当前榜一侧的排序,不改变该榜已经按 `RankPct` 筛出的候选集合。
|
||||||
|
|
||||||
|
### 上游来源与合理候选
|
||||||
|
|
||||||
|
OneChartLab 成分排行 manifest 声明个股涨跌幅来源为 `tushare.daily.pct_chg`,多日窗口的成分结果使用 `compound_return`。因此可确认的最小事实是:板块涨跌幅最终与成员股日行情有关,而不是从 score 反推出来。
|
||||||
|
|
||||||
|
对板块层聚合,至少存在以下候选:
|
||||||
|
|
||||||
|
```text
|
||||||
|
# 候选 A:成员等权的当日平均收益
|
||||||
|
board_return_t = mean_i(r_i,t)
|
||||||
|
|
||||||
|
# 候选 B:按成交额/规模加权
|
||||||
|
board_return_t = sum_i(w_i,t * r_i,t), sum_i(w_i,t)=1
|
||||||
|
|
||||||
|
# 候选 C:先形成板块净值,再计算净值收益
|
||||||
|
board_return_t = NAV_t / NAV_{t-1} - 1
|
||||||
|
```
|
||||||
|
|
||||||
|
其中 `r_i,t = daily.pct_chg / 100`。公开 payload 没有成员逐股权重、板块净值、自由流通市值或完整 point-in-time 成员快照,因此无法在 A/B/C 中做确定选择。manifest 的 `compound_return` 能确认多日收益的复合语义,但不能单独证明板块单日横截面采用哪一种权重。
|
||||||
|
|
||||||
|
### 为什么第一阶段容易“没有条件”
|
||||||
|
|
||||||
|
如果只有当前板块名称和一个最终涨跌幅,无法处理成员变更、停牌、上市生命周期、缺失行情和板块规模权重;如果用当前成员回填历史,会产生幸存者偏差。正确的复现输入至少要保存交易日级 `dc_member`、`stock_basic`、`suspend_d` 和 `daily`,并把无法确定的聚合公式作为独立实验版本。
|
||||||
|
|
||||||
|
## 二、加权评分 `Swing_Score` / `Ratio_Score`
|
||||||
|
|
||||||
|
### 页面行为
|
||||||
|
|
||||||
|
在截图视角中,“加权评分”显示约 985.0、976.4 等数值。源码 tooltip 原文为“后端特征算法算出的综合加权值”,前端只做读取、保留一位小数、排序和展示,没有 score 计算函数。
|
||||||
|
|
||||||
|
普通榜先按类型分池,再以 `RankPct >= 90` 和 `RankPct <= 10` 选取前后 10%。`Swing` 榜默认键是 `Swing_Score`,`Ratio` 榜默认键是 `Ratio_Score`;`Amount` 榜默认按 `Amount_Raw_BN`,即使记录带有 `Amount_Score` 也不使用它。由此可知 score 是排序特征,不是排名百分位本身。
|
||||||
|
|
||||||
|
### 数据证据:不是涨跌幅的简单变换
|
||||||
|
|
||||||
|
当前最新公开分片(`2026-08-31`,791 条记录,概念 414、行业 377)可重复观察到:
|
||||||
|
|
||||||
|
| 板块 | `pct_change` | `Swing_Ratio_Val` | `Swing_Score` | `Ratio_Raw_Pct` | `Ratio_Score` | `Amount_Raw_BN` |
|
||||||
|
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||||
|
| 知识产权 (概念) | 4.03 | 0.0320857 | 985.0391 | 0.1494700 | 988.6650 | 25.0232 |
|
||||||
|
| 盲盒经济 (概念) | 4.04 | 0.0465369 | 899.3116 | 0.2279457 | 952.2123 | 16.0316 |
|
||||||
|
| 元宇宙概念 (概念) | 2.83 | 0.0152171 | 976.3810 | 0.0695193 | 1032.4349 | 61.7966 |
|
||||||
|
|
||||||
|
“盲盒经济”的波段流入率和单日流入率都高于“知识产权”,但其两个 score 反而更低;“元宇宙概念”的 `Ratio_Score` 高于二者而 raw 流入率不最高。这排除了“score 只是 raw 值乘固定倍数”这一解释。
|
||||||
|
|
||||||
|
按当前分片计算的相关性也只能说明共同变化,不能证明公式:`pct_change → Ratio_Score` 在概念/行业约为 `r=0.714/0.584`,`pct_change → Swing_Score` 约为 `r=0.392/0.259`。对应 raw 值与 score 的相关性更高,但 `R²` 仍不是 1,且存在板块类型差异。因此“加权评分由涨跌幅+资金字段构成”目前只能列为合理推断,不能列为已确认事实。
|
||||||
|
|
||||||
|
### 可复现的最小替代策略
|
||||||
|
|
||||||
|
如果目标是先跑通系统而非伪装成原站,可定义独立版本,例如:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def independent_ratio_score(pct_change, ratio_raw, amount_raw):
|
||||||
|
# 所有标准化参数必须按 trade_date + type 保存并版本化。
|
||||||
|
z_pct = winsorized_zscore(pct_change, group="type")
|
||||||
|
z_ratio = winsorized_zscore(ratio_raw, group="type")
|
||||||
|
z_amount = winsorized_zscore(amount_raw, group="type")
|
||||||
|
return 500 + 100 * (0.30 * z_pct + 0.50 * z_ratio + 0.20 * z_amount)
|
||||||
|
```
|
||||||
|
|
||||||
|
这里的权重只是替代策略示例,不是 OneChartLab 参数。生产结果必须命名为自建 `metric_version`,同时保留与原站 score 的排名重合率、Spearman 相关、覆盖率和低流动性样本分层误差。
|
||||||
|
|
||||||
|
## 三、波段流入率 `Swing_Ratio_Val`
|
||||||
|
|
||||||
|
### 作者语义与页面字段
|
||||||
|
|
||||||
|
作者视频 `01:58—02:23` 将它解释为“统计 3—10 天加权的单日流入率并排名”,排名靠前意味着资金在板块内留存比例较高,后续行情延续性可能相对更好。这里的“可能”是使用启发式,不是收益保证。
|
||||||
|
|
||||||
|
页面显示时使用 `Swing_Ratio_Val * 100`。同一截图中左侧“流入 +3.21%”就是这一字段的百分比展示;右侧“流出 -5.60%”是负向榜板块的同一波段字段,不等于 `pct_change`。
|
||||||
|
|
||||||
|
### 可确认的结构与不可确认的参数
|
||||||
|
|
||||||
|
可以写成如下抽象形式:
|
||||||
|
|
||||||
|
```text
|
||||||
|
r_t = daily 主力净流入 / 当日成交额 # 作者口径;生产字段未确认
|
||||||
|
Swing_Ratio = Σ_{h∈H} w_h · aggregate(r_{t-h+1:t}), H ⊆ {3,...,10}
|
||||||
|
```
|
||||||
|
|
||||||
|
作者口头给出窗口范围 `3—10`,但没有说明是选择一个窗口、多个窗口协同,还是对 3 到 10 日结果再加权;也没有披露 `w_h`、缺失日处理、成员变更、截尾和板块低流动性门槛。
|
||||||
|
|
||||||
|
在当前公开日期分片上,把连续日期的 `Ratio_Raw_Pct` 与板块匹配后,最近 3 日简单均值与 `Swing_Ratio_Val` 最接近:概念和行业可匹配样本的 `R²` 约为 0.886/0.882;但拟合有非零斜率和截距,4—10 日候选更弱。因此“接近 3 日均值”是验证实验结果,不是原站公式。
|
||||||
|
|
||||||
|
### 为什么 Tushare 直接复现条件不足
|
||||||
|
|
||||||
|
Tushare 可提供 `moneyflow_dc.net_amount` 和 `daily.amount` 等候选字段,但还缺少原站精确的:
|
||||||
|
|
||||||
|
1. “主力流入”与“成交额”的字段口径和单位转换顺序;
|
||||||
|
2. 先逐股求比后聚合,还是先板块聚合后求比;
|
||||||
|
3. 3—10 日每个窗口的权重与窗口之间的协同方法;
|
||||||
|
4. 成员股停牌、缺失资金流、零分母、小样本和低流动性处理;
|
||||||
|
5. `Swing_Ratio_Val` 如何与 `Swing_Amount_Val` 合成为 `Swing_Score`。
|
||||||
|
|
||||||
|
## 三个指标之间的正确关系
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A[成员/行情/资金原始事实] --> B[pct_change:板块收益聚合]
|
||||||
|
A --> C[Ratio_Raw_Pct:单日资金流入率候选]
|
||||||
|
C --> D[Swing_Ratio_Val:3—10日加权波段特征]
|
||||||
|
B --> E[后端综合评分候选特征]
|
||||||
|
C --> E
|
||||||
|
D --> E
|
||||||
|
A --> E
|
||||||
|
E --> F[Swing_Score / Ratio_Score]
|
||||||
|
F --> G[按 type 分池、前后10%、排名百分位]
|
||||||
|
```
|
||||||
|
|
||||||
|
图中从 `B/C/D` 到 `E` 的箭头是合理推断,不是页面代码已证实的 score 公式。页面真正确认的是:`F` 已在后端生成,`G` 的排序和榜单筛选逻辑公开;`B/C/D` 的部分字段在页面中展示,但构建过程不公开。
|
||||||
|
|
||||||
|
## 可执行的验证实验
|
||||||
|
|
||||||
|
后续若能取得目标 Tushare 账号和连续数据,建议按以下顺序验证,而不是直接硬编码一个公式:
|
||||||
|
|
||||||
|
1. 对同一交易日、同一板块同时计算 `sum(net_amount)/sum(amount)`、板块级 `net_amount_rate`、逐股比例均值和成交额加权比例,与公开 `Ratio_Raw_Pct` 比较。
|
||||||
|
2. 保存至少 30 个连续交易日的 `Ratio_Raw_Pct`,比较 3、4、5、6…10 日候选窗口;用留出日期验证,不只做同日拟合。
|
||||||
|
3. 以 `pct_change`、单日流入率、波段流入率、净额、成员数量和流动性特征分别做消融实验,检查哪一组特征能解释 score 的横截面和时间序列变化。
|
||||||
|
4. 以概念/行业分开校准,并对小样本、停牌、零成交额和成员变更样本单独报告误差。
|
||||||
|
5. 用 Spearman 排名重合率而非只看 Pearson 相关,因为页面最终用途是排序;所有结果带 `metric_version`、`trade_date`、`type`、输入覆盖率和版本 hash。
|
||||||
|
|
||||||
|
## 最终复现建议
|
||||||
|
|
||||||
|
- 若目标是页面一致性:直接保存 OneChartLab 的日期分片和 score/raw 字段;本地只复刻格式化、排序、前后 10% 和排名变化。
|
||||||
|
- 若目标是独立生产:先实现 `pct_change_v1`、`ratio_v1`、`swing_v1` 和 `score_v1` 等明确命名的替代策略,不要命名为“原站算法”;把 Tushare 原始响应、成员快照和参数版本全部落库。
|
||||||
|
- 若目标是研究原站算法:需要作者或后端提供 score 特征定义、3—10 日权重、板块涨跌幅聚合和异常值规则;仅凭截图、最终 JSON 和 Tushare 公共字段无法唯一反演。
|
||||||
|
|
||||||
|
本文只用于技术与产品逻辑研究,不构成投资建议,也不代表替代指标具有预测能力。
|
||||||
|
|
||||||
|
## 主要证据
|
||||||
|
|
||||||
|
- [OneChartLab 首页](https://onechartlab.com/):当前表格模板、字段读取、格式化和排序逻辑。
|
||||||
|
- [`radar_manifest.json`](https://onechartlab.com/radar_manifest.json):日期分片入口和当前版本。
|
||||||
|
- [2026-08-31 日期分片](https://onechartlab.com/radar_data/dates/2026-08-31.c094ac3098c9.json):791 条记录与三个指标字段。
|
||||||
|
- [成分排行 manifest](https://onechartlab.com/sector_rankings/manifest.json):Tushare 字段来源、单位与 `compound_return` 契约。
|
||||||
|
- [作者视频](https://www.bilibili.com/video/BV16Hur6rEq2/):`01:36—02:23` 的单日流入率、波段流入率概念口径和限制说明。
|
||||||
|
- 既有板块资金雷达报告:本项目此前页面逻辑、排名和数据协议调研,仓库文件为 `docs/research/onechartlab-sector-capital-radar.md`。
|
||||||
@@ -175,8 +175,22 @@ describe("SectorRadarPage", () => {
|
|||||||
|
|
||||||
expect(screen.getAllByText("+12.5 亿元")[0]).toBeInTheDocument()
|
expect(screen.getAllByText("+12.5 亿元")[0]).toBeInTheDocument()
|
||||||
expect(screen.getAllByText("机器人")[0]).toBeInTheDocument()
|
expect(screen.getAllByText("机器人")[0]).toBeInTheDocument()
|
||||||
expect(screen.getAllByText("样本有限")[0]).toBeInTheDocument()
|
expect(screen.getAllByText("19/20")[0]).toBeInTheDocument()
|
||||||
expect(screen.getAllByText("95%")[0]).toBeInTheDocument()
|
expect(screen.getAllByText("100%")[0]).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.getAllByRole("columnheader", { name: "排名百分位" }),
|
||||||
|
).toHaveLength(2)
|
||||||
|
expect(screen.getAllByRole("columnheader", { name: "样本" })).toHaveLength(
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("columnheader", { name: "资金覆盖率" }),
|
||||||
|
).not.toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("columnheader", { name: "质量" }),
|
||||||
|
).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText("样本有限")).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText("95%")).not.toBeInTheDocument()
|
||||||
expect(screen.queryByText("收盘后板块资金排名")).not.toBeInTheDocument()
|
expect(screen.queryByText("收盘后板块资金排名")).not.toBeInTheDocument()
|
||||||
expect(
|
expect(
|
||||||
screen.queryByText("知行独立实现,非 OneChartLab 原站公式。"),
|
screen.queryByText("知行独立实现,非 OneChartLab 原站公式。"),
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
} from "@/features/sector-radar/api/sector-radar.query"
|
} from "@/features/sector-radar/api/sector-radar.query"
|
||||||
import type {
|
import type {
|
||||||
RadarMetricKind,
|
RadarMetricKind,
|
||||||
RadarMetricQuality,
|
|
||||||
RadarMetricUnit,
|
RadarMetricUnit,
|
||||||
RadarPublication,
|
RadarPublication,
|
||||||
RadarRankingRow,
|
RadarRankingRow,
|
||||||
@@ -18,7 +17,6 @@ import type {
|
|||||||
RadarView,
|
RadarView,
|
||||||
SectorRadarRouteSearch,
|
SectorRadarRouteSearch,
|
||||||
} from "@/features/sector-radar/api/sector-radar.types"
|
} from "@/features/sector-radar/api/sector-radar.types"
|
||||||
import { Badge } from "@/shared/ui/badge"
|
|
||||||
import { Button } from "@/shared/ui/button"
|
import { Button } from "@/shared/ui/button"
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -398,83 +396,71 @@ function RadarTable({
|
|||||||
role="region"
|
role="region"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
<table className="w-full min-w-[1280px] border-collapse text-sm">
|
<table className="w-full min-w-[960px] border-collapse text-sm">
|
||||||
<caption className="sr-only">
|
<caption className="sr-only">
|
||||||
{response.sector_type === "concept" ? "概念" : "行业"}板块资金双榜
|
{response.sector_type === "concept" ? "概念" : "行业"}板块资金双榜
|
||||||
</caption>
|
</caption>
|
||||||
<colgroup>
|
<colgroup>
|
||||||
{Array.from({ length: 12 }, (_, i) => (
|
{Array.from({ length: 8 }, (_, i) => (
|
||||||
<col
|
<col
|
||||||
key={i}
|
key={i}
|
||||||
className={i === 5 || i === 6 ? "w-[120px]" : "w-[100px]"}
|
className={i === 3 || i === 4 ? "w-[120px]" : "w-[100px]"}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</colgroup>
|
</colgroup>
|
||||||
<thead className="sticky top-0 z-10 bg-muted/95 text-xs text-muted-foreground backdrop-blur">
|
<thead className="sticky top-0 z-10 bg-muted/95 text-xs text-muted-foreground backdrop-blur">
|
||||||
<tr>
|
<tr>
|
||||||
<th
|
<th
|
||||||
className="px-3 py-2 text-left font-medium"
|
className="px-3 py-1 text-left font-medium"
|
||||||
colSpan={5}
|
colSpan={3}
|
||||||
scope="colgroup"
|
scope="colgroup"
|
||||||
>
|
>
|
||||||
资金进攻榜 TOP 10%
|
资金进攻榜 TOP 10%
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
className="border-x border-border px-3 py-2 text-center font-medium"
|
className="border-x border-border px-3 py-1 text-center font-medium"
|
||||||
colSpan={2}
|
colSpan={2}
|
||||||
scope="colgroup"
|
scope="colgroup"
|
||||||
>
|
>
|
||||||
{rankChangeView ? "排名变化" : response.definition.label}
|
{rankChangeView ? "排名变化" : response.definition.label}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
className="px-3 py-2 text-right font-medium"
|
className="px-3 py-1 text-right font-medium"
|
||||||
colSpan={5}
|
colSpan={3}
|
||||||
scope="colgroup"
|
scope="colgroup"
|
||||||
>
|
>
|
||||||
BOTTOM 10% 资金撤离榜
|
BOTTOM 10% 资金撤离榜
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-3 py-2 text-left font-medium" scope="col">
|
<th className="px-3 py-1 text-left font-medium" scope="col">
|
||||||
排名 / 板块
|
排名 / 板块
|
||||||
</th>
|
</th>
|
||||||
<th className="px-3 py-2 text-right font-medium" scope="col">
|
<th className="px-3 py-1 text-right font-medium" scope="col">
|
||||||
排名百分位
|
排名百分位
|
||||||
</th>
|
</th>
|
||||||
<th className="px-3 py-2 text-right font-medium" scope="col">
|
<th className="px-3 py-1 text-right font-medium" scope="col">
|
||||||
样本
|
样本
|
||||||
</th>
|
</th>
|
||||||
<th className="px-3 py-2 text-right font-medium" scope="col">
|
|
||||||
资金覆盖率
|
|
||||||
</th>
|
|
||||||
<th className="px-3 py-2 text-left font-medium" scope="col">
|
|
||||||
质量
|
|
||||||
</th>
|
|
||||||
<th
|
<th
|
||||||
className="border-l border-border px-3 py-2 text-right font-medium"
|
className="border-l border-border px-3 py-1 text-right font-medium"
|
||||||
scope="col"
|
scope="col"
|
||||||
>
|
>
|
||||||
{rankChangeView ? "排名上升" : "流入"}
|
{rankChangeView ? "排名上升" : "流入"}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
className="border-r border-border px-3 py-2 text-left font-medium"
|
className="border-r border-border px-3 py-1 text-left font-medium"
|
||||||
scope="col"
|
scope="col"
|
||||||
>
|
>
|
||||||
{rankChangeView ? "排名下降" : "流出"}
|
{rankChangeView ? "排名下降" : "流出"}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-3 py-2 text-right font-medium" scope="col">
|
<th className="px-3 py-1 text-right font-medium" scope="col">
|
||||||
质量
|
|
||||||
</th>
|
|
||||||
<th className="px-3 py-2 text-right font-medium" scope="col">
|
|
||||||
资金覆盖率
|
|
||||||
</th>
|
|
||||||
<th className="px-3 py-2 text-right font-medium" scope="col">
|
|
||||||
样本
|
样本
|
||||||
</th>
|
</th>
|
||||||
<th className="px-3 py-2 text-right font-medium" scope="col">
|
<th className="px-3 py-1 text-right font-medium" scope="col">
|
||||||
排名百分位
|
排名百分位
|
||||||
</th>
|
</th>
|
||||||
<th className="px-3 py-2 text-right font-medium" scope="col">
|
<th className="px-3 py-1 text-right font-medium" scope="col">
|
||||||
排名 / 板块
|
排名 / 板块
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -491,8 +477,8 @@ function RadarTable({
|
|||||||
{rowCount === 0 ? (
|
{rowCount === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
className="px-3 py-12 text-center text-muted-foreground"
|
className="px-3 py-8 text-center text-muted-foreground"
|
||||||
colSpan={12}
|
colSpan={8}
|
||||||
>
|
>
|
||||||
没有符合当前筛选条件的板块。
|
没有符合当前筛选条件的板块。
|
||||||
</td>
|
</td>
|
||||||
@@ -582,42 +568,49 @@ function RankCells({
|
|||||||
row?: RadarRankingRow
|
row?: RadarRankingRow
|
||||||
align: "left" | "right"
|
align: "left" | "right"
|
||||||
}) {
|
}) {
|
||||||
if (!row)
|
const rankClass = align === "right" ? "text-right" : "text-left"
|
||||||
return (
|
const rankCell = row ? (
|
||||||
<>
|
<td className={`px-3 py-1.5 ${rankClass} font-semibold tabular-nums`}>
|
||||||
<td className="px-3 py-2.5 text-muted-foreground">—</td>
|
|
||||||
<td />
|
|
||||||
<td />
|
|
||||||
<td />
|
|
||||||
<td />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<td
|
|
||||||
className={`px-3 py-2.5 ${align === "right" ? "text-right" : "text-left"} font-semibold tabular-nums`}
|
|
||||||
>
|
|
||||||
{row.rank_position ?? "—"}
|
{row.rank_position ?? "—"}
|
||||||
<span className="mx-1 font-normal text-muted-foreground">/</span>
|
<span className="mx-0.5 font-normal text-muted-foreground">/</span>
|
||||||
<span className="font-medium text-foreground">{row.sector_name}</span>
|
<span className="font-medium text-foreground">{row.sector_name}</span>
|
||||||
<span className="ml-1 text-xs font-normal text-muted-foreground">
|
<span className="ml-0.5 text-[11px] font-normal text-muted-foreground">
|
||||||
{row.sector_code}
|
{row.sector_code}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2.5 text-right tabular-nums">
|
) : (
|
||||||
|
<td className="px-3 py-1.5 text-muted-foreground">—</td>
|
||||||
|
)
|
||||||
|
const percentileCell = row ? (
|
||||||
|
<td className="px-3 py-1.5 text-right tabular-nums">
|
||||||
{row.rank_percentile === null
|
{row.rank_percentile === null
|
||||||
? "—"
|
? "—"
|
||||||
: `${formatNumber(row.rank_percentile, 2)}%`}
|
: `${formatNumber(row.rank_percentile, 2)}%`}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2.5 text-right tabular-nums">
|
) : (
|
||||||
|
<td />
|
||||||
|
)
|
||||||
|
const sampleCell = row ? (
|
||||||
|
<td className="px-3 py-1.5 text-right tabular-nums">
|
||||||
{row.valid_sample_count}/{row.member_count}
|
{row.valid_sample_count}/{row.member_count}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2.5 text-right tabular-nums">
|
) : (
|
||||||
{formatCoverage(row.moneyflow_coverage)}
|
<td />
|
||||||
</td>
|
)
|
||||||
<td className="px-3 py-2.5">
|
if (align === "right") {
|
||||||
<QualityBadge quality={row.quality} />
|
return (
|
||||||
</td>
|
<>
|
||||||
|
{sampleCell}
|
||||||
|
{percentileCell}
|
||||||
|
{rankCell}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{rankCell}
|
||||||
|
{percentileCell}
|
||||||
|
{sampleCell}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -652,7 +645,7 @@ function MetricCell({
|
|||||||
: "text-foreground bg-muted/30"
|
: "text-foreground bg-muted/30"
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
className={`${borderClass} border-border px-3 py-2.5 ${direction === "in" ? "text-right" : "text-left"} font-medium tabular-nums ${toneClass}`}
|
className={`${borderClass} border-border px-3 py-1.5 ${direction === "in" ? "text-right" : "text-left"} font-medium tabular-nums ${toneClass}`}
|
||||||
>
|
>
|
||||||
{value}
|
{value}
|
||||||
</td>
|
</td>
|
||||||
@@ -673,19 +666,6 @@ function formatDirectionalMetric(
|
|||||||
return `${direction === "in" ? "+" : "−"}${formatted}`
|
return `${direction === "in" ? "+" : "−"}${formatted}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function QualityBadge({ quality }: { quality: RadarMetricQuality }) {
|
|
||||||
if (quality === "available") return <Badge variant="outline">可用</Badge>
|
|
||||||
if (quality === "available_limited_sample")
|
|
||||||
return (
|
|
||||||
<Badge
|
|
||||||
className="border-amber-500/40 text-amber-700 dark:text-amber-200"
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
样本有限
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
return <Badge variant="destructive">不可用</Badge>
|
|
||||||
}
|
|
||||||
function RadarLoading() {
|
function RadarLoading() {
|
||||||
return (
|
return (
|
||||||
<Card aria-label="正在加载板块资金雷达" className="flex-1 shadow-none">
|
<Card aria-label="正在加载板块资金雷达" className="flex-1 shadow-none">
|
||||||
@@ -768,9 +748,6 @@ function formatRankChange(value: number | null) {
|
|||||||
if (value < 0) return `${value} 名`
|
if (value < 0) return `${value} 名`
|
||||||
return "0 名"
|
return "0 名"
|
||||||
}
|
}
|
||||||
function formatCoverage(value: number) {
|
|
||||||
return `${formatNumber(value * 100, 1)}%`
|
|
||||||
}
|
|
||||||
function formatNumber(value: number, maximumFractionDigits: number) {
|
function formatNumber(value: number, maximumFractionDigits: number) {
|
||||||
return value.toLocaleString("zh-CN", { maximumFractionDigits })
|
return value.toLocaleString("zh-CN", { maximumFractionDigits })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import {
|
|||||||
signalCategoryOptions,
|
signalCategoryOptions,
|
||||||
type SignalCategoryFilter,
|
type SignalCategoryFilter,
|
||||||
} from "./selection-presentation"
|
} from "./selection-presentation"
|
||||||
import { SignalTable } from "./signal-table"
|
|
||||||
|
|
||||||
const PAGE_SIZE_OPTIONS = [5, 10, 15] as const
|
const PAGE_SIZE_OPTIONS = [5, 10, 15] as const
|
||||||
const SCORE_SORT_OPTIONS: ReadonlyArray<{
|
const SCORE_SORT_OPTIONS: ReadonlyArray<{
|
||||||
@@ -195,15 +194,11 @@ export function SelectionResultsWorkbench({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex min-h-0 min-w-0 flex-col md:flex-1 md:overflow-hidden">
|
<div className="flex min-h-0 min-w-0 flex-col md:flex-1 md:overflow-hidden">
|
||||||
<SignalTable
|
|
||||||
onSelect={(stock) => setSelectedKey(getStockKey(stock))}
|
|
||||||
selectedKey={selectedStock ? getStockKey(selectedStock) : null}
|
|
||||||
stocks={currentStocks}
|
|
||||||
/>
|
|
||||||
<SignalRecordList
|
<SignalRecordList
|
||||||
expandedKeys={expandedKeys}
|
expandedKeys={expandedKeys}
|
||||||
onSelect={(stock) => setSelectedKey(getStockKey(stock))}
|
onSelect={(stock) => setSelectedKey(getStockKey(stock))}
|
||||||
onToggleExpanded={handleToggleExpanded}
|
onToggleExpanded={handleToggleExpanded}
|
||||||
|
selectedKey={selectedStock ? getStockKey(selectedStock) : null}
|
||||||
stocks={currentStocks}
|
stocks={currentStocks}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { ChevronDown, ChevronUp } from "lucide-react"
|
import { ChevronDown, ChevronUp } from "lucide-react"
|
||||||
|
import type { KeyboardEvent } from "react"
|
||||||
|
|
||||||
import { Badge } from "@/shared/ui/badge"
|
import { Badge } from "@/shared/ui/badge"
|
||||||
import { Button } from "@/shared/ui/button"
|
import { Button } from "@/shared/ui/button"
|
||||||
|
import { cn } from "@/shared/ui/utils"
|
||||||
|
|
||||||
import type { SelectionStockResult } from "../api/selection.types"
|
import type { SelectionStockResult } from "../api/selection.types"
|
||||||
import { PatternScoreDetails, PatternScoreSummary } from "./pattern-score"
|
import { PatternScoreDetails, PatternScoreSummary } from "./pattern-score"
|
||||||
@@ -15,6 +17,7 @@ import { SignalDetails } from "./signal-details"
|
|||||||
|
|
||||||
interface SignalRecordListProps {
|
interface SignalRecordListProps {
|
||||||
stocks: SelectionStockResult[]
|
stocks: SelectionStockResult[]
|
||||||
|
selectedKey: string | null
|
||||||
expandedKeys: ReadonlySet<string>
|
expandedKeys: ReadonlySet<string>
|
||||||
onSelect: (stock: SelectionStockResult) => void
|
onSelect: (stock: SelectionStockResult) => void
|
||||||
onToggleExpanded: (stock: SelectionStockResult) => void
|
onToggleExpanded: (stock: SelectionStockResult) => void
|
||||||
@@ -22,47 +25,64 @@ interface SignalRecordListProps {
|
|||||||
|
|
||||||
export function SignalRecordList({
|
export function SignalRecordList({
|
||||||
stocks,
|
stocks,
|
||||||
|
selectedKey,
|
||||||
expandedKeys,
|
expandedKeys,
|
||||||
onSelect,
|
onSelect,
|
||||||
onToggleExpanded,
|
onToggleExpanded,
|
||||||
}: SignalRecordListProps) {
|
}: SignalRecordListProps) {
|
||||||
|
function handleSelectKeyDown(
|
||||||
|
event: KeyboardEvent<HTMLButtonElement>,
|
||||||
|
stock: SelectionStockResult,
|
||||||
|
) {
|
||||||
|
if (event.key !== "Enter" && event.key !== " ") return
|
||||||
|
event.preventDefault()
|
||||||
|
onSelect(stock)
|
||||||
|
}
|
||||||
|
|
||||||
if (stocks.length === 0) {
|
if (stocks.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-32 items-center justify-center px-4 text-sm text-muted-foreground md:hidden">
|
<div className="flex min-h-32 flex-1 items-center justify-center overflow-auto px-4 text-sm text-muted-foreground">
|
||||||
没有符合当前筛选条件的信号。
|
没有符合当前筛选条件的信号。
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="divide-y divide-border/60 md:hidden">
|
<div className="min-h-0 flex-1 overflow-auto">
|
||||||
|
<div className="divide-y divide-border/60">
|
||||||
{stocks.map((stock) => {
|
{stocks.map((stock) => {
|
||||||
const key = getStockKey(stock)
|
const key = getStockKey(stock)
|
||||||
|
const selected = selectedKey === key
|
||||||
const expanded = expandedKeys.has(key)
|
const expanded = expandedKeys.has(key)
|
||||||
return (
|
return (
|
||||||
<article key={key} className="space-y-2 px-3 py-3">
|
<article
|
||||||
<button
|
key={key}
|
||||||
type="button"
|
className={cn(
|
||||||
className="flex min-h-11 w-full items-center justify-between gap-3 text-left outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
"space-y-1 px-2.5 py-1.5 transition-colors hover:bg-muted/50",
|
||||||
onClick={() => onSelect(stock)}
|
selected && "bg-muted/70",
|
||||||
|
)}
|
||||||
|
data-selected={selected}
|
||||||
>
|
>
|
||||||
<span className="min-w-0">
|
<div className="flex items-start gap-2">
|
||||||
<span className="block truncate font-medium text-foreground">
|
<button
|
||||||
|
className="min-h-11 min-w-0 flex-1 text-left outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
|
onClick={() => onSelect(stock)}
|
||||||
|
onKeyDown={(event) => handleSelectKeyDown(event, stock)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
{stock.name}
|
{stock.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono text-[10px] text-muted-foreground">
|
<span className="font-mono text-[10px] text-muted-foreground">
|
||||||
{stock.ts_code}
|
{stock.ts_code}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
<span className="ml-auto font-medium tabular-nums text-foreground">
|
||||||
<span className="shrink-0 font-medium tabular-nums text-foreground">
|
|
||||||
¥ {stock.close.toFixed(2)}
|
¥ {stock.close.toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</span>
|
||||||
<div className="text-xs">
|
<span className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
|
||||||
<PatternScoreSummary score={stock.score} />
|
<span className="flex flex-wrap gap-1">
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{stock.signals.map((signal) => (
|
{stock.signals.map((signal) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={signal.category}
|
key={signal.category}
|
||||||
@@ -72,17 +92,19 @@ export function SignalRecordList({
|
|||||||
{getCategoryLabel(signal.category)}
|
{getCategoryLabel(signal.category)}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</span>
|
||||||
<div className="flex items-center gap-1">
|
<PatternScoreSummary score={stock.score} />
|
||||||
<span className="text-xs tabular-nums text-muted-foreground">
|
<span className="tabular-nums text-muted-foreground">
|
||||||
J
|
J
|
||||||
<strong className="font-semibold text-foreground">
|
<strong className="font-semibold text-foreground">
|
||||||
{getStockJValue(stock)}
|
{getStockJValue(stock)}
|
||||||
</strong>
|
</strong>
|
||||||
</span>
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
<Button
|
<Button
|
||||||
aria-expanded={expanded}
|
aria-expanded={expanded}
|
||||||
className="min-h-11 gap-1 px-2 text-xs text-muted-foreground"
|
className="min-h-11 shrink-0 gap-1 px-2 text-xs text-muted-foreground"
|
||||||
onClick={() => onToggleExpanded(stock)}
|
onClick={() => onToggleExpanded(stock)}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
@@ -94,7 +116,6 @@ export function SignalRecordList({
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{expanded ? (
|
{expanded ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
@@ -120,5 +141,6 @@ export function SignalRecordList({
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
import type { KeyboardEvent } from "react"
|
|
||||||
|
|
||||||
import { Badge } from "@/shared/ui/badge"
|
|
||||||
|
|
||||||
import type { SelectionStockResult } from "../api/selection.types"
|
|
||||||
import { PatternScoreSummary } from "./pattern-score"
|
|
||||||
import {
|
|
||||||
categoryToneClass,
|
|
||||||
getCategoryLabel,
|
|
||||||
getStockJValue,
|
|
||||||
getStockKey,
|
|
||||||
} from "./selection-presentation"
|
|
||||||
|
|
||||||
interface SignalTableProps {
|
|
||||||
stocks: SelectionStockResult[]
|
|
||||||
selectedKey: string | null
|
|
||||||
onSelect: (stock: SelectionStockResult) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SignalTable({
|
|
||||||
stocks,
|
|
||||||
selectedKey,
|
|
||||||
onSelect,
|
|
||||||
}: SignalTableProps) {
|
|
||||||
function handleRowKeyDown(
|
|
||||||
event: KeyboardEvent<HTMLTableRowElement>,
|
|
||||||
stock: SelectionStockResult,
|
|
||||||
) {
|
|
||||||
if (event.key !== "Enter" && event.key !== " ") return
|
|
||||||
event.preventDefault()
|
|
||||||
onSelect(stock)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="hidden min-h-0 flex-1 overflow-auto md:block">
|
|
||||||
<table className="w-full border-collapse text-left text-xs">
|
|
||||||
<caption className="sr-only">知行 B1 命中信号明细</caption>
|
|
||||||
<thead className="sticky top-0 z-10 bg-card text-[11px] text-muted-foreground">
|
|
||||||
<tr className="border-b border-border/70">
|
|
||||||
<th className="h-9 px-3 font-medium">股票</th>
|
|
||||||
<th className="h-9 px-3 font-medium">信号类型</th>
|
|
||||||
<th className="h-9 px-3 text-right font-medium">图形评分</th>
|
|
||||||
<th className="h-9 px-3 text-right font-medium">J 值</th>
|
|
||||||
<th className="h-9 px-3 text-right font-medium">收盘价</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{stocks.length > 0 ? (
|
|
||||||
stocks.map((stock) => {
|
|
||||||
const selected = selectedKey === getStockKey(stock)
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
key={getStockKey(stock)}
|
|
||||||
aria-selected={selected}
|
|
||||||
className="cursor-pointer border-b border-border/50 outline-none transition-colors hover:bg-muted/50 focus-visible:bg-muted/60 data-[selected=true]:bg-muted/70"
|
|
||||||
data-selected={selected}
|
|
||||||
onClick={() => onSelect(stock)}
|
|
||||||
onKeyDown={(event) => handleRowKeyDown(event, stock)}
|
|
||||||
tabIndex={0}
|
|
||||||
>
|
|
||||||
<td className="h-11 px-3 align-middle">
|
|
||||||
<p className="font-medium text-foreground">{stock.name}</p>
|
|
||||||
<p className="font-mono text-[10px] text-muted-foreground">
|
|
||||||
{stock.ts_code}
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 align-middle">
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{stock.signals.map((signal) => (
|
|
||||||
<Badge
|
|
||||||
key={signal.category}
|
|
||||||
className={categoryToneClass(signal.category)}
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
{getCategoryLabel(signal.category)}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 text-right align-middle">
|
|
||||||
<PatternScoreSummary score={stock.score} />
|
|
||||||
</td>
|
|
||||||
<td className="px-3 text-right align-middle font-semibold tabular-nums text-foreground">
|
|
||||||
{getStockJValue(stock)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 text-right align-middle font-medium tabular-nums text-foreground">
|
|
||||||
¥ {stock.close.toFixed(2)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
className="h-32 px-3 text-center text-sm text-muted-foreground"
|
|
||||||
colSpan={5}
|
|
||||||
>
|
|
||||||
没有符合当前筛选条件的信号。
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -143,31 +143,18 @@ describe("SelectionResultsPage", () => {
|
|||||||
|
|
||||||
expect(screen.getByText("执行完成")).toBeInTheDocument()
|
expect(screen.getByText("执行完成")).toBeInTheDocument()
|
||||||
expect(screen.queryByText("目标股票数")).not.toBeInTheDocument()
|
expect(screen.queryByText("目标股票数")).not.toBeInTheDocument()
|
||||||
const table = screen.getByRole("table")
|
expect(screen.queryByRole("table")).not.toBeInTheDocument()
|
||||||
expect(table).toBeInTheDocument()
|
const record = screen.getByRole("article")
|
||||||
expect(
|
expect(within(record).getByText("平安银行")).toBeInTheDocument()
|
||||||
within(table).getByRole("columnheader", { name: "信号类型" }),
|
expect(within(record).getByText("000001.SZ")).toBeInTheDocument()
|
||||||
).toBeInTheDocument()
|
expect(within(record).getByText("原始 B1")).toBeInTheDocument()
|
||||||
expect(
|
expect(within(record).getByText("回踩白线 B")).toBeInTheDocument()
|
||||||
within(table).getByRole("columnheader", { name: "J 值" }),
|
expect(within(record).getByText("86.40 分")).toBeInTheDocument()
|
||||||
).toBeInTheDocument()
|
expect(within(record).getByText("12")).toBeInTheDocument()
|
||||||
expect(
|
expect(within(record).queryByText("13")).not.toBeInTheDocument()
|
||||||
within(table).getByRole("columnheader", { name: "图形评分" }),
|
expect(within(record).getByText("¥ 10.50")).toBeInTheDocument()
|
||||||
).toBeInTheDocument()
|
expect(within(record).getByText("华纳药厂")).toBeInTheDocument()
|
||||||
expect(
|
expect(screen.getByRole("button", { name: /平安银行/ })).toBeInTheDocument()
|
||||||
within(table).queryByRole("columnheader", { name: "关键详情" }),
|
|
||||||
).not.toBeInTheDocument()
|
|
||||||
expect(within(table).getByText("原始 B1")).toBeInTheDocument()
|
|
||||||
expect(within(table).getByText("回踩白线 B")).toBeInTheDocument()
|
|
||||||
expect(within(table).getByText("12")).toBeInTheDocument()
|
|
||||||
expect(within(table).queryByText("13")).not.toBeInTheDocument()
|
|
||||||
expect(within(table).getByText("平安银行")).toBeInTheDocument()
|
|
||||||
expect(within(table).getByText("86.40 分")).toBeInTheDocument()
|
|
||||||
expect(within(table).getByText("华纳药厂")).toBeInTheDocument()
|
|
||||||
|
|
||||||
const mobileRecord = screen.getAllByRole("article")[0]
|
|
||||||
expect(within(mobileRecord).getByText("12")).toBeInTheDocument()
|
|
||||||
expect(within(mobileRecord).queryByText("13")).not.toBeInTheDocument()
|
|
||||||
|
|
||||||
const detailPanel = screen.getByRole("complementary", {
|
const detailPanel = screen.getByRole("complementary", {
|
||||||
name: "当前股票详情",
|
name: "当前股票详情",
|
||||||
@@ -249,9 +236,9 @@ describe("SelectionResultsPage", () => {
|
|||||||
render(<SelectionResultsPage />)
|
render(<SelectionResultsPage />)
|
||||||
|
|
||||||
expect(screen.getAllByText(expected).length).toBeGreaterThan(0)
|
expect(screen.getAllByText(expected).length).toBeGreaterThan(0)
|
||||||
const table = screen.getByRole("table")
|
const record = screen.getByRole("article")
|
||||||
expect(within(table).getByText("原始 B1")).toBeInTheDocument()
|
expect(within(record).getByText("原始 B1")).toBeInTheDocument()
|
||||||
expect(within(table).getByText("12")).toBeInTheDocument()
|
expect(within(record).getByText("12")).toBeInTheDocument()
|
||||||
if (score?.status === "below_threshold") {
|
if (score?.status === "below_threshold") {
|
||||||
const detailPanel = screen.getByRole("complementary", {
|
const detailPanel = screen.getByRole("complementary", {
|
||||||
name: "当前股票详情",
|
name: "当前股票详情",
|
||||||
@@ -558,16 +545,27 @@ describe("SelectionResultsPage", () => {
|
|||||||
fireEvent.change(screen.getByLabelText("搜索命中股票"), {
|
fireEvent.change(screen.getByLabelText("搜索命中股票"), {
|
||||||
target: { value: "招商" },
|
target: { value: "招商" },
|
||||||
})
|
})
|
||||||
const table = screen.getByRole("table")
|
const extraRecord = screen
|
||||||
expect(within(table).getByText("招商银行")).toBeInTheDocument()
|
.getAllByRole("article")
|
||||||
fireEvent.click(within(table).getByText("招商银行"))
|
.find((article) => within(article).queryByText("招商银行"))
|
||||||
|
expect(extraRecord).toBeTruthy()
|
||||||
|
if (!extraRecord) {
|
||||||
|
throw new Error("expected 招商银行 card to be visible")
|
||||||
|
}
|
||||||
|
const selectButton = within(extraRecord).getByRole("button", {
|
||||||
|
name: /招商银行/,
|
||||||
|
})
|
||||||
|
fireEvent.keyDown(selectButton, { key: "Enter" })
|
||||||
expect(
|
expect(
|
||||||
within(
|
within(
|
||||||
screen.getByRole("complementary", { name: "当前股票详情" }),
|
screen.getByRole("complementary", { name: "当前股票详情" }),
|
||||||
).getByText("招商银行"),
|
).getByText("招商银行"),
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
|
|
||||||
const expandButton = screen.getAllByRole("button", { name: "查看详情" })[1]
|
fireEvent.keyDown(selectButton, { key: " " })
|
||||||
|
const expandButton = within(extraRecord).getByRole("button", {
|
||||||
|
name: "查看详情",
|
||||||
|
})
|
||||||
fireEvent.click(expandButton)
|
fireEvent.click(expandButton)
|
||||||
expect(expandButton).toHaveAttribute("aria-expanded", "true")
|
expect(expandButton).toHaveAttribute("aria-expanded", "true")
|
||||||
expect(screen.getAllByText("MACD").length).toBeGreaterThan(1)
|
expect(screen.getAllByText("MACD").length).toBeGreaterThan(1)
|
||||||
|
|||||||
Reference in New Issue
Block a user