Compare commits

...
Author SHA1 Message Date
FoXiMao db45838e22 docs(FEAT-MO-005): 对齐最终 GitNexus 基线 2026-08-25 02:39:34 +08:00
FoXiMao 21393ef451 docs(FEAT-MO-005): 完善市场结构功能追溯档案 2026-08-25 02:38:06 +08:00
FoXiMao 64a9f2e866 feat(FEAT-MO-005): 展示市场结构与成交额趋势 2026-08-25 02:33:21 +08:00
FoXiMao b95b9e5c92 feat(FEAT-MO-005): 接入市场结构前端数据层 2026-08-25 02:10:22 +08:00
FoXiMao ad40ffffc4 feat(FEAT-MO-005): 增加全市场成交额趋势接口 2026-08-25 02:04:00 +08:00
FoXiMao 7b76dd1353 feat(FEAT-MO-005): 增加行业题材资金流向接口 2026-08-25 01:56:51 +08:00
FoXiMao 8174f3f698 feat(FEAT-MO-005): 增加行业题材板块排行接口 2026-08-25 01:51:23 +08:00
FoXiMao b6a9f40ed8 chore(FEAT-MO-005): 同步OpenAPI生成契约 2026-08-25 01:44:50 +08:00
FoXiMao 548f9c4b8b feat(FEAT-MO-005): 建立市场结构数据映射边界 2026-08-25 01:42:59 +08:00
FoXiMao a019aaa343 test(FEAT-MO-005): 补充探针地址边界测试 2026-08-25 01:34:13 +08:00
FoXiMao 50b5266903 docs(FEAT-MO-005): 建立市场结构数据源探针 2026-08-25 01:27:41 +08:00
FoXiMao 4f5a56df3a docs(FEAT-MO-005): 编写市场结构实施计划 2026-08-25 01:06:48 +08:00
FoXiMao bdfab99b10 docs(FEAT-MO-005): 固化市场结构与成交额趋势设计 2026-08-25 01:00:30 +08:00
FoXiMao a1b4d8c591 docs(FEAT-MO-004): 修正收盘后分时时效结论 2026-08-25 00:51:58 +08:00
FoXiMao 86de51f184 docs(GOV-010): 更新GitNexus索引状态 2026-08-25 00:49:50 +08:00
FoXiMao cf520193b3 docs(FEAT-MO-004): 记录AKTools分时能力探针 2026-08-25 00:48:34 +08:00
FoXiMao bdb7923f75 docs(FEAT-MO-004): 固化最终GitNexus状态 2026-08-25 00:27:12 +08:00
FoXiMao 40a24549bc docs(FEAT-MO-004): 更新GitNexus最终基线 2026-08-25 00:25:45 +08:00
FoXiMao 50b6318d25 docs(FEAT-MO-004): 完成详情页治理追溯 2026-08-25 00:24:11 +08:00
FoXiMao 0131f863b6 feat(FEAT-MO-004): 实现指数板块详情分析页 2026-08-25 00:16:43 +08:00
87 changed files with 6144 additions and 22 deletions
+1 -1
View File
@@ -29,7 +29,7 @@
<!-- gitnexus:start --> <!-- gitnexus:start -->
# GitNexus — Code Intelligence # GitNexus — Code Intelligence
This project is indexed by GitNexus as **a-share-analysis** (3472 symbols, 4950 relationships, 104 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. This project is indexed by GitNexus as **a-share-analysis** (4266 symbols, 6330 relationships, 147 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
@@ -0,0 +1,49 @@
# AKTools 分时能力探针报告
- 功能关联:FEAT-MO-004 指数与板块详情分析页
- 探针类型:Spike(只读能力验证,不包含业务代码改动)
- 探针时间:2026-08-25(Asia/Shanghai)
- 服务地址:`http://127.0.0.1:8080`(仅记录地址,不记录凭据和原始载荷)
- AKTools 版本:`1.18.88`
## 验证范围
验证 AKTools 暴露的 AKShare 分钟接口,覆盖个股、指数、行业板块和概念板块;同时检查新浪分钟接口是否可以作为指数/个股的备用来源。每次请求仅执行只读查询,不触发交易、账户、自选或写入操作。
## 实测结果
| 数据类别 | 接口 | 参数样例 | HTTP | 结果摘要 |
| --- | --- | --- | ---: | --- |
| 个股 | `stock_zh_a_hist_min_em` | `symbol=000001&period=1` | 500(重复探测出现过 200) | 依赖东方财富 push2his;本次重复请求不稳定,不纳入当前接入 |
| 个股 | `stock_zh_a_minute` | `symbol=sh600519&period=1` | 200 | 返回 1,970 条;字段为 `day/open/high/low/close/volume/amount` |
| 指数 | `stock_zh_a_minute` | `symbol=sh000001&period=1` | 200 | 返回 1,970 条;可覆盖上证指数 |
| 指数 | `stock_zh_a_minute` | `symbol=sz399001&period=1` | 200 | 返回 1,970 条;可覆盖深证成指 |
| 指数 | `stock_zh_a_minute` | `symbol=sz399006&period=1` | 200 | 返回 1,970 条;可覆盖创业板指 |
| 指数 | `index_zh_a_hist_min_em` | `symbol=399006&period=1/5` | 500 | 依赖东方财富指数映射和 push2his;当前环境不可用 |
| 行业板块 | `stock_board_industry_hist_min_em` | `symbol=小金属&period=1/5` | 500 | 当前环境不可用;接口内部还依赖板块名称映射 |
| 概念板块 | `stock_board_concept_hist_min_em` | `symbol=长寿药&period=1/5` | 500 | 当前环境不可用;接口内部还依赖概念名称映射 |
新浪分钟接口返回的最新时间为 `2026-08-24 15:00:00`。本次探针在收盘后执行,最后一条记录可能只是最近一个交易日的收盘数据;仅凭收盘后的单次探测,不能判定接口存在延迟,也不能据此标记为实时行情。
## 结论
1. AKShare 确实具备分时能力;当前内网 AKTools 已能通过 `stock_zh_a_minute` 返回个股和主要指数分钟序列。
2. `stock_zh_a_hist_min_em`、`index_zh_a_hist_min_em`、行业板块和概念板块分钟接口均依赖东方财富上游,本次环境中出现 HTTP 500 或重复不稳定,暂不具备上线条件。
3. 新浪接口的字段结构与现有详情页 TDX K 线模型不同;本次收盘后探测不足以确认盘中时效,不能直接替换 TDX,也不能在页面上未经状态判断标记为实时分时。
4. FEAT-MO-004 当前 `availableViews.intraday=false` 的行为保持不变。本报告不授权新增接口或修改数据源优先级。
## 推荐后续方案
如需继续接入分时,应另立功能并先完成以下边界设计:
- 个股/指数可考虑新浪分钟接口作为分时补充,但状态必须依据交易时段探测结果判断,并记录数据源和最后数据时间;收盘后的单次结果不能直接标记为 `delayed` 或 `fresh`。
- 板块分时暂不接入 AKShare,除非先解决东方财富上游 500、板块代码映射和重试限流问题。
- 不把 AKTools 原始接口暴露给浏览器;由 Nest 服务端固定白名单、超时、响应体上限、字段校验和错误脱敏。
- 继续保留 TDX 作为实时/盘中主数据源;任何补充源都不能覆盖 TDX 的新鲜数据。
- 上线前需要在交易时段连续观测至少一个完整交易日,比较最后时间、更新时间延迟、缺口率、请求成功率和重复数据率;收盘后的结果只用于确认收盘记录是否完整。
## 限制与残余风险
- 探针在本机内网环境执行,结果受当前网络代理、东方财富/新浪上游状态和交易日影响,不能代表生产可用性。
- 未保存原始返回载荷,避免把行情快照和上游异常细节写入仓库;仅保存接口、状态码、字段和时间摘要。
- 未验证授权、再分发和长期 7×24 稳定性;在这些事项完成前不得把接口标为生产数据源。
@@ -0,0 +1,15 @@
# FEAT-MO-004 设计说明
设计基线为 `docs/superpowers/specs/2026-08-24-index-sector-detail-design.md`。
后端新增详情 Controller/Service/Mapper,复用现有 TDX Client 的超时、响应体上限和错误脱敏边界;指数和板块使用独立代码白名单和数据口径。前端新增详情路由、独立 Axios API 文件、共享详情页和轻量 SVG K 线图,概览按钮仅负责导航。
详情数据失败采用模块级降级,页面仍保留标题、来源、更新时间和 warning。分时能力不通过探测时不返回假数据,UI 只展示明确的不可用边界。
## 安全策略与评估
Controller/Service 双层参数校验、代码白名单、固定周期、数量上限、响应字段白名单和错误脱敏。详情不接受任意 TDX 方法名或地址,分时未验证时不伪造数据。测试覆盖非法代码、越界数量、TDX 超时和原始字段不泄漏;残余风险是 TDX 本身依赖本机隧道,公网部署前必须另立采集架构评估。
## 性能目标与评估
单次最多 120 点、固定图表尺寸、页面卸载释放图表状态,不新增数据库和缓存热点。本地真实 TDX 详情请求观测约 47-56ms,浏览器首屏在开发机可交互;P95 小于 2 秒是本地开发目标,不代表生产容量证明。
@@ -0,0 +1,34 @@
# FEAT-MO-004 指数与板块详情分析页开发总结
- 功能编号:FEAT-MO-004
- 状态:in_review
- 发布状态:unreleased
## 原始开发需求
市场综合概览中的指数、板块点击后进入详细分析页,使用 TDX 真实数据展示行情和 K 线,支持后续复盘查看。
## 需求与范围变化
已确认首版同时覆盖指数和一级行业板块,但拆成共享外壳下的两个数据切片。TDX 能力探测发现三个分时候选方法均返回协议错误,因此首版只发布日 K 线,分时明确标记不可用。未扩大到成分股、资金流、新闻、AI、用户体系或交易操作。
## 实际完成需求
新增指数和板块详情 API、DTO、TDX 日 K 线映射、代码/参数白名单、统一数据状态和错误脱敏;新增 Vue 详情路由、Axios API、事实摘要、轻量 SVG K 线和响应式布局;概览指数与板块入口已接通真实导航;OpenAPI 和前端生成契约已同步。
## 修复缺陷
实现期间修复了板块目录记录字段与内部定义字段名不一致导致详情不可用的问题;修复了前端详情页在接口异步完成前测试读取旧加载状态的问题;修复了 Web 严格类型下可空涨跌幅的显示类型错误;补充了详情模块注册到 OpenAPI 导出入口,避免契约遗漏。
## 未完成与延期项
分时数据延期,等待 TDX 提供可验证协议;成分股排名、资金净流入、盘中新闻、AI 结论、数据库/Redis/RocketMQ 持久化和公网采集代理另立功能。当前详情页仍依赖开发机 TDX 隧道,不作为公网生产采集方案。
## 关键提交
`0131f86 feat(FEAT-MO-004): 实现指数板块详情分析页`,已推送项目 Gitea 分支 `codex/feat-mo-004-index-sector-detail`。
## 验证证据
TDX 脱敏能力报告记录日 K 线和一级行业目录通过、分时候选失败;GitNexus 开发前风险为 MEDIUM,开发后检测到预期 API 模块注册、OpenAPI、路由和概览导航影响;自动化、构建、视觉和移动端证据见 `test-plan.md`、`qa/test-results.md`、`qa/visual-qa.md`。
@@ -0,0 +1,24 @@
# GitNexus 开发后基线
## GitNexus 状态
```text
Repository: /Users/citrons/Documents/Codex/2026-08-12/wo-x/a-share-analysis
Indexed: 8/25/2026, 12:26:06 AM
Indexed commit: 40a2454
Current commit: 40a2454
Status: ✅ up-to-date
```
## Git 已跟踪变更摘要
```text
实现提交 `0131f86` 与治理提交 `50b6318` 已完成,当前工作区仅保留用户未纳入本功能的 CSV 文件。
5 files changed, 11 insertions(+), 6 deletions(-)
```
## Git 工作区摘要
```text
?? 用户未纳入本功能的 `SRMS3.6.0常见问题测评题.2csv`
```
@@ -0,0 +1,28 @@
# GitNexus 开发前基线
- 功能编号:FEAT-MO-004
- 记录时间:2026年8月24日 GMT+8 23:37:42
- 分支:main
- 基线提交:f8f4da93dc8f
## 工作区
```text
?? "SRMS3.6.0\345\270\270\350\247\201\351\227\256\351\242\230\346\265\213\350\257\204\351\242\230.2csv"
?? docs/features/FEAT-MO-004-index-sector-detail/
?? docs/superpowers/plans/2026-08-24-index-sector-detail.md
```
## GitNexus 状态
```text
Repository: /Users/citrons/Documents/Codex/2026-08-12/wo-x/a-share-analysis
Indexed: 8/24/2026, 11:37:41 PM
Indexed commit: f8f4da9
Current commit: f8f4da9
Status: ✅ up-to-date
```
## 后续人工分析
在修改代码前,将 GitNexus query、context、impact 的结论写入 `gitnexus/impact-plan.md`。高风险结果必须先获得用户确认。
@@ -0,0 +1,11 @@
# GitNexus 前后对比
| 指标 | 开发前 | 开发后 |
| --- | ---: | ---: |
| 索引提交 | `f8f4da9` | `0131f86`及收尾文档提交 |
| 节点 | 3506 | 3786 |
| 关系 | 4983 | 5466 |
| 执行流 | 104 | 115 |
| 变更风险 | 开发前 `MarketOverviewService` MEDIUM,其余 LOW | `detect_changes` MEDIUM,影响为预期模块注册/路由/契约 |
执行流数量变化来自详情 Controller/Service、Mapper、页面和测试被 GitNexus 纳入索引;收尾后的 3 个 mapper 流程是 lint 修复后的预期细化,没有发现跨基础设施的意外扩散。完整状态保存在 `before.md` 与 `after.md`。
@@ -0,0 +1,18 @@
# GitNexus 开发后变更检测
- 执行:`gitnexus_detect_changes(scope=all)`(实现提交前与文档收尾后各执行一次)
- 实现提交前结果:`risk_level=medium`,实现变更落在 API 模块注册、OpenAPI 导出、路由和概览导航,受影响执行流 2 个。
- 收尾后结果:`risk_level=medium`,5 个收尾变更文件、3 个 `mapDetailQuote` 相关执行流,均为预期的注释/文档和 lint 修复。
## 预期变更
- API:`MarketOverviewModule.register` 注册详情 Controller/Service;OpenAPI 导出接入详情 Controller/Service。
- Web:新增详情路由;概览页指数/板块入口改为真实导航。
- 文档:功能档案和 GitNexus 统计更新。
## 受影响执行流
- `RunApi → CreateTdxClient`:模块注册步骤变化,已有 API 启动和详情 E2E 已覆盖。
- `RunApi → CreateAktoolsClient`:模块注册步骤变化,已有短线和进程烟测已覆盖。
未发现数据库、Redis、RocketMQ、Scheduler、Worker 或部署文件的非预期变更;两次检测均未出现 HIGH/CRITICAL。
@@ -0,0 +1,37 @@
# GitNexus 开发前影响计划
- 功能编号:FEAT-MO-004
- 总体风险:MEDIUM(既有概览服务扩展会触及 Controller、模块、服务测试和 OpenAPI 导出;无 HIGH/CRITICAL)
## 直接与间接影响
### 查询与上下文
- GitNexus 仓库:`a-share-analysis`,开发前索引与 `f8f4da9` 一致且 up-to-date。
- 影响目标:`MarketOverviewController`、`MarketOverviewService`、`createTdxClient`、`createAppRouter`。
- `MarketOverviewController` upstream:3 个直接文件消费者(概览 E2E、OpenAPI 导出、模块注册),总影响 7 个文件,风险 LOW。
- `MarketOverviewService` upstream:5 个直接文件消费者(概览 E2E、服务测试、OpenAPI 导出、Controller、模块),总影响 9 个文件,风险 MEDIUM。
- `createTdxClient` upstream:TDX Client 单元测试 1 个直接消费者,风险 LOW;详情复用其稳定 `request<T>()` 边界,不修改协议实现。
- `createAppRouter` upstream:`App.test.ts` 1 个直接消费者,风险 LOW;新增路由需要补充路由测试和根路径回归。
- `MarketOverviewPage` 未被索引为符号,按文件级人工检查确认只消费概览 composable 和两个选择事件;新增导航不改变概览请求契约。
- 执行流:详情是新增只读 HTTP 流程;既有首页五个概览流程仅通过模块注册和共享 TDX Client 间接关联,需回归验证。
## 跨模块同步修改
| 范围 | 判断 | 同步动作 |
| --- | --- | --- |
| HTTP/OpenAPI | 需要 | 新增两个详情路由、DTO、生成 OpenAPI 并校验 |
| Web API | 需要 | 新增详情 Axios 文件与类型适配 |
| 消息契约 | 不需要 | 只读同步请求,不产生消息 |
| 数据库/Redis | 不需要 | 不持久化、不缓存,且不触碰生产库 |
| API 进程 | 需要 | 注册详情 Controller/Service,复用已校验 TDX Client |
| Scheduler/Worker | 不需要 | 无异步任务或定时采集 |
| 配置/部署 | 不需要 | 继续使用现有 TDX 配置和内网隧道,不新增环境变量 |
| 测试 | 需要 | Mapper、Service、HTTP、Web 路由/状态、视觉和全量门禁 |
| 文档 | 需要 | 功能档案、TDX 探测、开发总结、Review、QA、GitNexus 前后对比 |
### 设计决策
1. 不扩展首页接口大对象,详情接口独立,降低既有消费者的契约风险。
2. 不修改 `createTdxClient`,只复用其超时、响应体上限和错误脱敏;分时能力未验证时明确返回不可用。
3. 指数和板块共享页面外壳与 K 线点类型,但保留各自白名单和业务摘要映射。
@@ -0,0 +1,6 @@
# FEAT-MO-004 实施记录
详细实施计划见 [`docs/superpowers/plans/2026-08-24-index-sector-detail.md`](../../../superpowers/plans/2026-08-24-index-sector-detail.md)。
执行顺序:开发前 Schema/GitNexus 门禁 → TDX 能力探测 → Mapper → Service/Controller → Web 路由和详情页 → 契约、测试、视觉验证、Review 和治理收尾。
@@ -0,0 +1,8 @@
featureId: FEAT-MO-004
slug: index-sector-detail
name: 指数与板块详情分析页
status: in_review
release: unreleased
source: tdx
scope: internal_lan_only
createdAt: 2026-08-24
@@ -0,0 +1,29 @@
# FEAT-MO-004 指数与板块详情分析页 PRD
## 原始需求
市场综合概览中的指数、板块可点击进入详细分析页,支持使用 TDX 真实数据进行复盘查看。
## 用户与场景
- 个人用户在概览页发现指数或强势板块后,进入详情确认交易日期、价格变化和 K 线走势。
- 内网开发环境优先,TDX 通过当前 Mac 本机隧道访问;不把 TDX 暴露到公网。
## V1 范围
- 指数详情和一级行业板块详情。
- 共享详情页外壳、数据状态、更新时间、来源、警告和 K 线图。
- 分时视图由 TDX 能力探测结果决定,未通过时明确不可用。
- 事实型数据和规则计算结果,不提供买卖建议、目标价、仓位建议或交易操作。
## 非目标
成分股排名、主力净流入、盘中新闻、AI 结论、用户体系、订阅付费和数据库/Redis/RocketMQ 持久化均不在本版本。
## 验收标准
1. 点击概览中的指数或板块可进入对应详情 URL,非法代码不触发 TDX 请求。
2. API 返回 `instrument`、`quote`、`series`、`availableViews` 和统一元数据,K 线不超过 120 点。
3. TDX 不可达、交易日延迟、字段缺失和空序列分别显示明确状态,不伪造图表。
4. Web 显示上海时间,桌面和 390px 视口无横向溢出,图表容器尺寸稳定。
@@ -0,0 +1,14 @@
# FEAT-MO-004 QA 测试结果
## 1. 自动化门禁
`pnpm verify` 已通过运行时、契约、边界、部署、Schema、Web 34 测试、Server 94 测试、typecheck、构建和格式检查。Lint 仅保留仓库既有 Vue 规则 warning,无 error。
## 2. API 与真实 TDX
真实开发 API 已加载新详情路由;指数详情返回 TDX 真实收盘价、交易日期和 120 个 K 线点,板块详情先读一级行业目录再读取 K 线。历史交易日被标记为 `delayed`,不被当作当日实时。
## 3. 残余风险
TDX 分时协议仍不可用;当前测试未证明公网网络、生产容量或长时间隧道稳定性。详情功能保持内网开发范围。
@@ -0,0 +1,14 @@
# FEAT-MO-004 视觉 QA
## 1. 桌面视口
在 1440x900 浏览器中,详情页标题、来源、更新时间、交易日、状态、核心行情、K 线、分时边界和事实摘要均可见;SVG 图表有真实非空路径,详情内容宽度 1400px,文档宽度等于视口 1440px。
## 2. 移动视口
在 390x844 浏览器中,详情页折叠为单列核心行情,图表容器保持稳定,`innerWidth=390`、`scrollWidth=390`、`body.scrollWidth=390`,无横向溢出;标题和状态文字未互相遮挡。
## 3. 交互验收
概览页指数按钮导航到 `/market-overview/indices/000001.SH`,板块按钮导航到 `/market-overview/sectors/881001.SH`;返回概览按钮可用;TDX 不可用和分时不可用状态均展示明确文案,不显示伪造图表。
@@ -0,0 +1,16 @@
# FEAT-MO-004 发布说明
## 当前状态
未发布,功能分支已完成实现和 Review,等待合并决策。
## 用户可见变化
- 综合概览的核心指数和一级行业按钮可进入详情页。
- 详情页展示 TDX 来源、上海时间、交易日期、状态、最新收盘价、涨跌幅和 K 线。
- TDX 分时能力未验证时显示明确不可用边界。
## 部署边界
仅适用于当前内网开发隧道;不改变数据库结构,不启用 Redis/RocketMQ,不暴露 TDX 到公网。
@@ -0,0 +1,23 @@
# FEAT-MO-004 需求与约束
## 功能需求
- FR-001:提供指数详情 HTTP API。
- FR-002:提供一级行业板块详情 HTTP API。
- FR-003:详情 API 只接受登记代码、`period=1d` 和 `1..120` 的 `count`。
- FR-004:详情响应包含 K 线序列、报价、交易日期、状态、来源和警告。
- FR-005:概览指数和板块按钮导航到详情页,详情页可返回概览。
- FR-006:分时仅在 TDX 探测通过后启用,未启用时返回 `intraday=false`。
## 非功能需求
- NFR-001:TDX 原始异常和地址不得暴露给 Web。
- NFR-002:本机 TDX 可达时详情首屏 P95 目标小于 2 秒;不可达时 10 秒内返回不可用状态。
- NFR-003:详情请求失败不影响首页五个概览模块。
- NFR-004:API `asOf` 使用 UTC ISO,Web 使用 `Asia/Shanghai` 展示。
- NFR-005:前端请求只能经过 Axios 封装,后端 DTO 作为 OpenAPI 契约来源。
## 约束判断
消息、数据库、Redis、Scheduler、Worker 和部署配置无需同步修改;详情为只读实时查询,不产生异步任务或持久化数据。OpenAPI、Web API、前端路由、测试和文档需要同步更新。
@@ -0,0 +1,20 @@
# FEAT-MO-004 代码 Review
## 安全与残余风险
通过。Controller 和 Service 双层限制代码、周期和数量;TDX 方法名、地址、原始错误和业务字段不直接暴露给 Web;响应体点数固定不超过 120。残余风险为 TDX 隧道依赖和分时能力缺失,已在 API 状态和文档中显式呈现。
## 性能与资源影响
通过。详情按单代码读取,不引入数据库、Redis、RocketMQ 或异步任务;K 线数量和 SVG viewBox 固定,页面无定时刷新和大对象缓存。真实开发探测耗时约 47-56ms;生产容量仍未证明。
## GitNexus 与同步修改
通过。开发前 `MarketOverviewService` 影响为 MEDIUM,其余入口 LOW;开发后 `detect_changes` 仅报告 API 模块注册、OpenAPI 导出、前端路由和概览导航的预期变更,RunApi 两条启动流程已回归测试。HTTP/OpenAPI、Web API、测试和文档同步;消息、数据库、Redis、Scheduler、Worker、部署无需同步。
## 发现与处置
未发现阻断问题。已修复板块目录字段映射、前端异步状态测试和可空涨跌幅类型问题。
最终结论:passed
@@ -0,0 +1,16 @@
# FEAT-MO-004 开发规范符合性 Review
## 安全、性能与架构检查
通过。后端按市场概览业务模块内的 detail 职责组织 DTO、Mapper、Service、Controller;前端 API 独立文件并统一经过 Axios request;关键边界有中文注释;未过度抽取工具类;未新增不必要依赖;没有密钥、密码、生产数据或 Docker 变更。
## GitNexus 与开发总结检查
通过。功能开发前执行 Schema baseline/verify、governance:start、GitNexus impact 和 TDX 脱敏探测;开发后刷新 GitNexus、执行 detect_changes、保存前后报告;开发总结明确原始需求、范围变化、实际完成、修复缺陷、延期项、关键提交和验证证据。
## 版本控制与发布检查
通过。提交使用中文 Conventional Commit `feat(FEAT-MO-004): 实现指数板块详情分析页`,已推送项目级 Gitea 功能分支;未修改全局 Git 配置;回退无需数据库数据回滚。
最终结论:passed
@@ -0,0 +1,6 @@
# FEAT-MO-004 回退说明
详情功能为无数据库结构变更的只读模块。若发布后出现问题,先回退前端详情路由和概览导航,再回退后端详情 Controller/Service 注册;首页已有五个概览接口不依赖详情模块。回退不执行生产数据库操作、不删除 TDX 配置、不修改用户数据。
回退验证包括:`GET /api/v1/market-overview/indices`、`sectors` 仍返回 200;Web 根路径可加载;OpenAPI 与生成契约重新生成并通过边界测试。
@@ -0,0 +1,29 @@
# FEAT-MO-004 TDX 能力探测报告
- 探测日期:2026-08-24(开发环境)
- 数据源:TDX 开发隧道(地址和请求体按安全规则不记录)
- 探测方式:只读 JSON-RPC 请求,输出仅保留状态码、耗时、字段名和脱敏代码。
## 已通过能力
| 能力 | 方法 | 结果 | 观测 |
| --- | --- | --- | --- |
| 指数日 K 线 | `get_market_data` | 通过 | HTTP 200,业务 `ErrorId=0`,返回 `Value` 及分页元数据;两条核心指数请求耗时约 56ms |
| 一级行业目录 | `get_stock_list` | 通过 | HTTP 200,业务 `ErrorId=0`,返回数组,首个脱敏记录代码为 `881001.SH` |
| 一级行业日 K 线 | `get_market_data` | 通过 | 对目录首个代码请求 5 条日线,HTTP 200,业务 `ErrorId=0`,返回对应 `Value`;耗时约 47ms |
## 未通过能力
| 能力 | 候选方法 | 结果 | 处理 |
| --- | --- | --- | --- |
| 分时 | `get_minute_data` | 未通过 | 服务返回协议错误,未暴露原始错误内容;V1 返回 `availableViews.intraday=false` |
| 分时 | `get_minute_data_ex` | 未通过 | 服务返回协议错误;不继续猜测参数或伪造分时序列 |
| 历史分时 | `get_history_minute_data` | 未通过 | 服务返回协议错误;延期到独立能力验证 |
## 安全和稳定性结论
- 所有探测均为固定只读方法,未调用交易、账户、自选或写入接口。
- 日 K 线字段沿用已验证的 `Date/Open/High/Low/Close/Amount/Volume`;详情服务仍会二次校验字段长度、日期和数量上限。
- 真实响应只用于能力判断,不作为提交到仓库的原始样本;报告不包含地址、请求体、凭据或原始异常。
- 本次观测不代表生产容量证明;详情页仍按单请求最多 120 点和现有 TDX 超时配置执行。
@@ -0,0 +1,25 @@
# FEAT-MO-004 测试计划与结果
## 测试范围
- Mapper:日期排序、非法行过滤、数量上限、缺失成交字段和涨跌幅计算。
- Service:指数/板块成功、目录校验、延迟、部分数据、不可用和超时脱敏。
- HTTP:两个详情路由、代码格式、周期和数量参数、OpenAPI 响应结构。
- Web:Axios API 路径、路由加载、上海时间、空/错误/延迟状态、K 线 SVG 和移动端布局。
## 安全验证
已验证非法代码不会触发 TDX;`count` 只接受 1-120;页面和 HTTP 响应不包含 `ErrorId`、TDX 地址或原始异常;分时协议未通过时 `intraday=false`。未执行交易、账户、自选或写入接口。
## 性能验证
详情服务固定最多 120 个 K 线点,真实开发 TDX 日 K 线请求观测约 47-56ms;SVG 图表固定 `viewBox=0 0 720 260`,390px 视口 `documentElement.scrollWidth=390`,无横向溢出。该证据仅代表本机开发环境,不代表生产容量。
## 自动化结果
- Web:12 个测试文件、34 个测试通过。
- Server:18 个测试文件、94 个测试通过。
- 根级治理、契约、边界、部署和 Schema 测试全部通过。
- Playwright:桌面/移动应用冒烟和 API 健康契约共 4 个测试通过。
- Web/Server typecheck、生产构建和 Prettier 检查通过;lint 无 error,保留既有 Vue 格式 warning。
@@ -0,0 +1,25 @@
# FEAT-MO-005 数据源能力探针
## 结论
收盘后于 2026-08-25(Asia/Shanghai)执行两次本机只读探测。TDX 三市场日线可用,`Date` 与 `Amount` 均等长;1/5/15 分钟请求业务成功但序列为空。AKTools 五个候选中,市场资金流曾可用一次、随后 HTTP 500,归类为 `partial`;其余候选为 HTTP 500 或超时,归类为 `unavailable`。
两次均在非交易时段,最后一条记录仅为收盘后最后记录,不能推断盘中新鲜度。交易时段观测尚未执行,不能将任一来源标为实时。
## 脱敏能力表
| 来源/接口 | 分类 | 两次收盘后摘要 | 字段与时间结论 |
| --- | --- | --- | --- |
| TDX `get_market_data`,三市场 `1d` | `usable` | 30 行;63-148ms | `Date`/`Amount` 对齐;首末日期为 20260714/20260824;非分时。 |
| TDX `get_market_data`,三市场 `1m`/`5m`/`15m` | `partial` | 0 行;63-193ms | 无时间序列,不能形成盘中成交额趋势。 |
| AKTools `stock_board_industry_spot_em` | `unavailable` | HTTP 500;约 5.2-5.5 秒 | 未取得字段或时间。 |
| AKTools `stock_board_concept_spot_em` | `unavailable` | 10 秒 timeout | 未取得字段或时间。 |
| AKTools `stock_sector_fund_flow_rank` | `unavailable` | HTTP 500;117-144ms | 未取得字段或时间。 |
| AKTools `stock_market_fund_flow` | `partial` | 第一次 120 行/172ms,第二次 HTTP 500/130ms | 可用时含日期和各档净流入净额/净占比;不可作为稳定排行源。 |
| AKTools `stock_zh_a_spot_em` | `unavailable` | HTTP 500;约 5.6-5.9 秒 | 未取得字段或时间。 |
未保存原始行、请求体、凭据、上游错误内容或非本机地址。HTTP 500 与 timeout 作为不同短错误类别记录。
## 后续准入
Tasks 2-4 只能按 `design.md` 中的映射实现明确的不可用/部分可用降级;不得接入行业、题材、板块排行或盘中成交额趋势。交易时段需要至少一次复测,以验证最后时间与实际时段的新鲜度。
@@ -0,0 +1,27 @@
# FEAT-MO-005 设计说明
## 方案
探针固定请求五个 AKTools 候选和 TDX `get_market_data` 的三只指数、四种周期。输出仅允许状态、耗时、行数、字段名、首末时间与短错误类别;原始行、请求体、凭据和非本机 URL 不记录。
后续结构模块以 TDX 日线的 `Date`、`Amount` 作为全市场日度成交额快照依据。分钟周期为空时,成交额趋势不得伪装为盘中分时;行业、题材与排行资金流接口未通过时,API 只返回不可用状态。
## 字段定义与来源映射
| 指标 | 字段口径 | 映射结论 |
| --- | --- | --- |
| 全市场日度成交额 | 三市场同交易日 `Amount` 相加,TDX 单位万元转亿元 | 已验证快照派生(TDX 主来源) |
| 全市场盘中成交额趋势 | `Date` 与 `Amount` 对齐的 1/5/15 分钟序列 | 延期为不可用,当前分钟序列为空 |
| 行业、题材、板块排行 | 上游可证明的名称、涨跌幅、成交额字段 | 延期为不可用,候选接口失败 |
| 行业、题材、板块资金流排行 | 上游明确的净流入字段和定义 | 延期为不可用,排行接口失败 |
| 全市场资金流历史 | AKTools `主力/超大/大/中/小单净流入-净额` 等明示字段 | AKTools 补充源,当前为 `partial`,不能用于排行 |
## 安全策略与评估
资产为本机桥接可返回的行情、配置中的 TDX 地址和任何凭据。信任边界位于 Node 探针与本机回环服务之间:参数被固定白名单限制,URL 仅接受 `127.0.0.1` 固定端口,10 秒超时和 2 MiB 响应上限限制上游异常。输出仅为脱敏摘要,错误不包含上游响应。认证、授权、SQL、XSS、CSRF 与 CORS 不适用于该无 HTTP 入站、无持久化、无浏览器页面的本地 CLI;其边界是后续 Nest API 必须重新验证。
残余风险是本机桥接和免费上游不稳定,且未验证盘中延迟、授权和再分发要求;任何生产接入前必须单独审查。
## 性能目标与评估
单次探针固定为 17 个请求,单请求上限 10 秒、响应上限 2 MiB,不保存载荷。收盘后两次观测中 TDX 单请求为 63-193ms,AKTools 出现 117ms 至 10,004ms 的失败/超时,故不把本地观测视为容量结论;本低频开发工具不做负载测试。
@@ -0,0 +1,52 @@
# FEAT-MO-005 开发总结
- 功能编号:FEAT-MO-005
- 日期:2026-08-25
- 发布状态:未发布,当前分支已推送 Gitea
## 原始开发需求
综合概览需要覆盖全部板块、题材和行业的涨跌幅排行、资金流入/流出排行,以及全天成交额趋势图;用户可点击进入详细分析,分时 K 线不作为本轮首页重点。
## 需求与范围变化
- 根据数据源探针结果,行业/题材/资金流保留固定 AKTools 适配,但当前不可用时必须保守降级。
- 独立 API 继续按模块拆分,没有把整个首屏合并成一个聚合接口。
- 板块分类未验证独立上游端点,因此保留切换入口但返回 `unavailable`。
- 没有引入数据库、Redis、RocketMQ、AI 或用户体系。
## 实际完成需求
| 需求 | 状态 | 结果 |
| --- | --- | --- |
| 行业/题材/板块强弱排行 | 已完成 | 新增 `SectorRankingPanel`,支持分类、涨跌/成交额排序和详情事件。 |
| 资金流向排行 | 已完成 | 新增 `CapitalFlowPanel`,只展示上游明示净额,缺失值显示未提供。 |
| 全天成交额趋势 | 已完成 | 新增 `TurnoverTrendPanel`,支持累计亿元和 SVG 趋势线。 |
| 前端分类真实刷新 | 已完成 | composable 分类切换会重新请求对应 API。 |
| 既有模块兼容 | 已完成 | 指数、宽度、板块概览、量能和短线情绪均保留。 |
## 修复缺陷
E2E 首次运行发现 `MarketStructureController` 未使用显式 Nest 服务注入,三个新路由因此返回 400。已增加 `@Inject(MarketStructureService)`,服务端回归测试和 E2E 均通过。
## 未完成与延期项
AKTools 交易时段实时性、行业/题材/资金流生产稳定性和独立板块数据源仍待验证;本轮不承诺实时生产数据。详情页 K 线和 AI 分析不在本功能范围。
## 关键提交
| 提交 | 内容 | 推送 |
| --- | --- | --- |
| `50b5266`..`b6a9f40` | 数据源探针、领域映射和契约 | 已推送 |
| `8174f3f`..`ad40fff` | 三路结构与成交额 API | 已推送 |
| `b95b9e5` | Web 数据层 | 已推送 |
| `64a9f2e` | 首屏面板、响应式样式、注入修复 | 已推送 |
## 验证证据
- Web:14 个 Vitest 文件、39 个测试;类型检查通过。
- Server:21 个 Vitest 文件、110 个测试及 Node 冒烟通过。
- 根级 lint、Prettier、typecheck、test、build 通过;lint 仅有既有 Vue 风格 warning,无 error。
- Playwright:桌面和 390px 移动视口共 4 项通过,无横向溢出和运行时错误。
- 1440px/390px 截图已检查;AKTools 不可用状态展示符合降级设计。
- GitNexus 提交前检测:中风险,10 文件/47 符号/1 条刷新流程,已完成回归覆盖。
@@ -0,0 +1,23 @@
# GitNexus 开发后基线
## GitNexus 状态
```text
Repository: /Users/citrons/Documents/Codex/2026-08-12/wo-x/a-share-analysis
Indexed: 8/25/2026, 2:38:36 AM
Indexed commit: 21393ef
Current commit: 21393ef
Status: ✅ up-to-date
```
## Git 已跟踪变更摘要
```text
(没有未提交差异)
```
## Git 工作区摘要
```text
?? "SRMS3.6.0\345\270\270\350\247\201\351\227\256\351\242\230\346\265\213\350\257\204\351\242\230.2csv"
```
@@ -0,0 +1,26 @@
# GitNexus 开发前基线
- 功能编号:FEAT-MO-005
- 记录时间:2026年8月25日 GMT+8 01:15:39
- 分支:codex/feat-mo-005-market-structure-turnover
- 基线提交:4f5a56df3a71
## 工作区
```text
?? "SRMS3.6.0\345\270\270\350\247\201\351\227\256\351\242\230\346\265\213\350\257\204\351\242\230.2csv"
```
## GitNexus 状态
```text
Repository: /Users/citrons/Documents/Codex/2026-08-12/wo-x/a-share-analysis
Indexed: 8/25/2026, 1:15:38 AM
Indexed commit: 4f5a56d
Current commit: 4f5a56d
Status: ✅ up-to-date
```
## 后续人工分析
在修改代码前,将 GitNexus query、context、impact 的结论写入 `gitnexus/impact-plan.md`。高风险结果必须先获得用户确认。
@@ -0,0 +1,12 @@
# GitNexus 前后对比
| 项目 | 开发前 | 开发后 | 判断 |
| --- | --- | --- | --- |
| 索引节点 | 4f5a56d 基线 | 4,210 nodes | 新增结构领域和前端组件已纳入索引 |
| 关系/流程 | 5,473 relationships / 115 flows | 6,274 edges / 147 flows | 新增组件调用与 API 流程,未改变既有核心数据源流程 |
| 提交影响 | 无本功能代码 | 10 文件、47 符号、1 条刷新流程 | 中风险,测试已覆盖 |
| 生产边界 | 无数据库/消息改动 | 仍无数据库、Redis、RocketMQ、部署改动 | 保持边界 |
## 残余风险
GitNexus 只描述静态关系,不能证明 AKTools 交易时段新鲜度;该项由数据源探针和后续生产观测负责。
@@ -0,0 +1,13 @@
# GitNexus 提交前变更检测
```text
命令:npx gitnexus detect-changes --scope staged --repo a-share-analysis
结果:10 files, 47 symbols
Affected processes: 1
Risk level: medium
流程:Refresh → LoadModule(新增 refreshSectorRankings/refreshCapitalFlow)
```
## 结论
变更集中在市场概览页面、composable、结构面板和一个控制器注入修复;没有数据库、消息、缓存、部署或生产配置变更。中风险来自刷新执行流扩展,已由 composable 单测、服务端测试和 Playwright 覆盖。
@@ -0,0 +1,36 @@
# GitNexus 开发前影响计划
- 功能编号:FEAT-MO-005
- 总体风险:中。新增结构边界本身独立,但共享响应 DTO 有 24 个上游影响项,必须保持旧接口字段和 `source=tdx` 行为兼容。
## 直接与间接影响
### `MarketOverviewMetaDto`(MEDIUM)
- 直接影响 12 项:现有 summary、indices、breadth、sectors、funds 响应 DTO,TDX mapper、详情 DTO/mapper、controller 和 service。
- 间接影响 12 项:概览和详情 e2e、OpenAPI 导出、模块注册及相关测试。
- 决策:新增数据源联合类型时只扩展 `source` 的允许值,不改变既有 TDX 响应结构;所有旧测试必须继续通过。
### `MarketOverviewService`(MEDIUM)
- 直接影响 7 项:概览 e2e、服务测试、OpenAPI 导出、模块/controller 和详情服务引用。
- 间接影响 6 项:API 入口和进程冒烟测试。
- 决策:新结构服务独立注入,不重写现有 `MarketOverviewService` 的业务流程;仅在模块注册层增加 provider/controller。
### `createAktoolsClient`(LOW)
- 直接影响 1 项:现有 AKTools client 测试。
- 决策:不扩展短线 client 的固定端点,新增 `AktoolsStructureClient`,避免行业/资金接口与短线情绪接口耦合。
GitNexus `query("市场概览 板块排行 资金流入流出 成交额趋势 TDX AKTools")` 定位到现有概览流程、TDX mapper 和 AKTools 模块;本任务后续只新增 structure 领域文件,不改变已验证的短线流程。
## 跨模块同步修改
| 范围 | 判断 | 说明 |
| --- | --- | --- |
| HTTP/OpenAPI | 需要 | Tasks 3–5 新增三个独立只读路由,Task 6 重新生成契约 |
| Web API/前端 | 需要 | Task 6 添加 Axios API 和 composable 状态,Task 7 添加面板 |
| 消息契约、数据库、Redis、Scheduler、Worker | 不需要 | 本切片只读请求,无持久化、缓存和异步任务 |
| API 模块注册 | 需要 | `market-overview.module.ts` 增加结构 controller/provider |
| 配置部署 | 不需要 | 复用已存在的本机 TDX/AKTools 配置,不新增凭据或地址 |
| 测试与文档 | 需要 | 新增 mapper/service/API/UI/E2E 测试和完整功能档案 |
@@ -0,0 +1,22 @@
# FEAT-MO-005 实施计划
## 目标
在综合概览保留现有指数、市场宽度、板块概览、量能和短线情绪的基础上,增加行业/题材/板块排行、资金流向和全天成交额趋势,并让每个模块独立处理 `fresh`、`delayed`、`partial`、`unavailable`。
## 已执行步骤
1. 完成 TDX/AKTools 能力探针和脱敏记录。
2. 建立市场结构领域类型、映射和 AKTools 固定端点客户端。
3. 增加三个独立 Nest API 路由并生成 OpenAPI 契约。
4. 接入 Web Axios API 分域和 composable 独立状态。
5. 实现三个 Vue 面板、分类切换、轻量 SVG 趋势图和响应式样式。
6. 完成服务端注入回归、组件测试、全量构建、E2E 和视觉检查。
## 约束与回退
不新增数据库、Redis、RocketMQ 或异步任务;不保存原始行情载荷;AKTools 未启用时页面展示不可用状态。若上游字段口径不明确,保持 `null`/`unavailable`,禁止从涨跌或成交额推导资金流。
## 安全策略与性能目标
复用既有服务端来源白名单、Axios 边界和脱敏错误。前端仅消费 Nest API,不接触 TDX/AKTools 地址或凭据。列表请求限制为 Top 100,排序和 SVG 点计算为线性复杂度,组件不引入新运行时依赖。剩余风险是免费上游在交易时段的稳定性和延迟尚未完成生产级验证。
@@ -0,0 +1,10 @@
featureId: FEAT-MO-005
slug: market-structure-turnover
name: 市场结构与成交额趋势
status: in_development
release: unreleased
scope: local_development_probe
createdAt: 2026-08-25
sources:
tdx: local_loopback_primary
aktools: local_loopback_supplement
@@ -0,0 +1,16 @@
# FEAT-MO-005 产品需求
## 目标
为市场概览补充行业、题材、板块强弱排行,明确口径的资金流向,以及全市场成交额趋势;每个模块独立显示来源、交易日期与可用状态。
## 用户价值与验收
- 用户可在一个页面比较行业、题材、板块的涨跌幅和成交额,缺失字段不以零填充。
- 资金流向只展示上游明确提供的净流入字段及其定义,禁止由涨跌或成交额推导。
- 成交额统一为亿元,盘中新鲜度和收盘后最后记录分别表达。
- 上游失败时保留已验证数据或显示 `partial`/`unavailable`,不提供离线样本。
## 范围边界
本功能只读取本机 TDX 与 AKTools 桥接服务;不连接生产环境、不写数据库、不调用交易、账户或自选接口。上游授权、稳定性和再分发权未验证前,不作为生产数据源承诺。
@@ -0,0 +1,18 @@
# FEAT-MO-005 测试结果
## 自动化结果
- `pnpm lint`:通过,0 error;仓库既有 Vue 风格 warning 未扩大为错误。
- `pnpm format:check`:通过。
- `pnpm typecheck`:Web/Server 均通过。
- `pnpm test`:治理、契约、边界、部署、Web、Server 全部通过;Web 39 tests,Server 110 tests。
- `pnpm build`:Web Vite 和 Server API/Scheduler/Worker 构建通过。
- `pnpm verify:e2e`:4/4 通过,桌面 1440px 和移动 390px 均无横向溢出。
## 安全与性能证据
未新增凭据、原始载荷、生产地址或持久化写入。排行限制 1-100,前端仅通过 Axios 封装访问 API;SVG 趋势点计算和本地排序均为 O(n),n 为已限制返回项/点数。
## 失败处置
首次 E2E 发现三个结构路由返回 400,定位为控制器缺少显式服务注入;修复后服务端 21 文件/110 测试与 E2E 4 项全部通过。AKTools 在测试配置中禁用,结构和趋势模块按约定显示 unavailable,未伪造样本数据。
@@ -0,0 +1,18 @@
# FEAT-MO-005 视觉 QA
## 视口
- 桌面:1440 × 900,完整页面截图检查通过。
- 移动:390 × 900,完整页面截图检查通过。
## 检查项
- 新增排行、资金流、成交额趋势和短线情绪模块按独立网格排列,无卡片嵌套。
- 移动端网格变为单列,排行行的辅助字段隐藏,文字不溢出。
- 不可用/延迟/部分数据状态使用既有警告样式,未出现空白或 `0.00` 伪造资金值。
- 页面 `scrollWidth === clientWidth`,桌面和移动均无横向滚动。
- 成交额趋势在真实 unavailable 状态显示原因;有点位时 SVG polyline 具备非空点集并保持固定高度。
## 残余风险
当前 AKTools 未启用,无法进行真实排行数据的视觉密度检查;后续上游可用时需复核长行业名称和 100 条列表边界。
@@ -0,0 +1,16 @@
# FEAT-MO-005 发布说明
## 本次内容
- 综合概览新增行业、题材、板块强弱排行。
- 新增行业/题材/板块资金流向面板,保留上游字段口径和缺失状态。
- 新增全市场全天成交额趋势面板,单位为亿元,支持轻量 SVG 趋势线。
- 分类切换触发独立 API 刷新,既有概览模块保持不变。
## 数据与限制
当前 AKTools 未启用或不可用时,相关模块显示 `unavailable`;未验证的板块端点不会伪造数据。该功能未引入数据库、Redis、RocketMQ、AI、用户体系或生产部署变更。
## 发布状态
代码已提交并推送分支 `codex/feat-mo-005-market-structure-turnover`,待合并和后续真实交易时段数据源验证后再发布。
@@ -0,0 +1,17 @@
# FEAT-MO-005 工程需求
## 数据与状态
- 服务端固定白名单访问上游,浏览器只访问 Nest API。
- 状态只能为 `fresh`、`delayed`、`partial`、`unavailable`;收盘后最后记录不等于盘中新鲜。
- `turnoverAmount`、`cumulativeAmount` 统一为亿元;`netInflow` 仅使用上游明示的净额字段。
- 每条输出保留 `source`、`tradeDate`、`asOf` 与适用的字段定义;未知字段返回 `null` 或省略。
## 同步影响判定
| 项目 | 本任务结论 | 边界 |
| --- | --- | --- |
| HTTP/OpenAPI、Web API | 不修改 | 本任务仅能力探针与档案。 |
| 消息、数据库、Redis、Scheduler、Worker、部署 | 不修改 | 只读本机调用,无持久化和后台任务。 |
| API 数据源适配器 | 后续任务需要 | 仅接入本报告标为可用或部分可用且有明确降级的接口。 |
| 测试与文档 | 已修改 | Node 探针测试和功能档案共同记录证据。 |
@@ -0,0 +1,26 @@
# FEAT-MO-005 代码 Review
## 安全与残余风险
PASS。前端没有新增直连上游、凭据或原始响应暴露;组件只消费生成 DTO,资金缺失保持 `null` 语义。控制器显式注入服务,避免运行时未定义调用。残余风险为 AKTools 免费服务的授权、稳定性和交易时段延迟未经生产验证。
## 性能与资源影响
PASS。排行请求仍受服务端 Top 100 白名单限制,前端排序是有限数组上的 O(n),SVG 趋势点是 O(n),没有新增运行时依赖或持久化资源。全量构建和 E2E 已验证页面负载边界。
## GitNexus 与同步修改
PASS。GitNexus 检测为中风险,1 条刷新流程受影响;已同步更新 composable、页面、组件、样式和测试。HTTP/OpenAPI、数据库、Redis、RocketMQ、Scheduler、Worker、部署边界均按影响计划复核,只有已有结构 API 和 Web 消费者需要变更。
## 发现与处置
| 严重程度 | 发现 | 处置 |
| --- | --- | --- |
| Important | 新控制器未显式注入服务导致 E2E 400 | 增加 `@Inject(MarketStructureService)`,回归通过 |
| Minor | 上游资金流字段可能缺失 | 显示 `未提供`,禁止推导零值 |
## 结论
代码符合当前设计和安全边界,未发现阻断发布的问题。
最终结论:passed
@@ -0,0 +1,22 @@
# FEAT-MO-005 开发规范 Review
## 安全、性能与架构检查
PASS。实现沿用 Nest 业务模块、Axios request 封装和独立 API 文件夹;没有绕过前端请求边界,没有引入 Docker、数据库或消息依赖。中文注释覆盖数据口径和异常降级;响应列表、SVG 和分类请求均有明确边界。
## GitNexus 与开发总结检查
PASS。开发前影响计划、提交前检测、开发后索引和前后对比已归档;`development-summary.md` 明确记录原始需求、范围变化、完成项、缺陷、延期项、提交和验证证据。用户原有未跟踪 CSV 未被修改或纳入提交。
## Review 证据
- 中文 Conventional Commit:`feat(FEAT-MO-005): 展示市场结构与成交额趋势`。
- 当前分支已推送项目 Gitea,未修改全局 Git 配置。
- Schema 基线和开发库约束未被本功能绕过;本轮无数据库对象。
- 根级验证、服务端/前端测试、构建和桌面/移动视觉检查均有记录。
## 结论
未发现违反项目代码开发规范、流程规范或文档追溯要求的问题。
最终结论:passed
@@ -0,0 +1,5 @@
# FEAT-MO-005 回退说明
本任务仅新增本地只读探针与文档,无运行时服务、数据库迁移、消息、缓存或部署变更。若探针产生误导性结果,删除后续对该映射的引用并恢复到本提交前的功能档案;不得通过重试把失败源标记为可用。
后续 API 一旦错误标记盘中新鲜、泄漏上游信息或使用未验证净流入口径,应立即关闭对应固定白名单端点,返回 `unavailable`,并保留最后已验证快照的实际日期。
@@ -0,0 +1,18 @@
# FEAT-MO-005 测试计划
## 自动化测试
- `node --test tools/probes/market-structure-capability.test.mjs`:验证 CLI 明示固定本机和只读边界;验证 TDX 列式 `Date` 与 `Amount` 等长、分钟时间戳的对齐判定。
- 手工只读探测:收盘后连续执行两次,记录状态、耗时、行数、字段、首末时间与错误类别;交易时段另执行一次。
## 安全验证
验证 `--base-url` 拒绝非回环地址和错误端口;响应体超过 2 MiB、无效 JSON、HTTP 500 与超时仅归类,不输出载荷、请求体或凭据。当前测试覆盖 CLI 边界和列式字段;上游异常路径由本机实测覆盖,后续适配器需补全固定样本契约测试。
## 性能验证
记录每个固定调用耗时,10 秒超时可重复验证。该探针不产生数据库、缓存、消息或前端渲染负载;交易时段连续观测尚未完成,不能证明新鲜度或 P95 容量。
## 通过条件与残余风险
只有映射表已记录、探针不泄漏原始内容、自动化测试通过且三次时段观测齐备,才可将具体源升级为生产候选。当前缺少交易时段观测,源映射维持保守状态。
@@ -0,0 +1,163 @@
# 指数与板块详情分析页实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在市场综合概览中实现可点击的指数详情页,并在同一外壳上接入一级行业板块详情;只展示经过 TDX 校验的事实数据和规则计算结果。
**Architecture:** 后端新增详情业务模块,复用现有 TDX Client 和概览元数据,但不把详情数据拼入首页接口。详情 Controller 负责参数白名单与查询上限,Service 按指数/板块分别读取和映射日 K 线,Web 通过独立 Axios API 和共享详情页面外壳展示状态、K 线与可用视图;分时能力只有在探测报告通过后才启用。
**Tech Stack:** NestJS 11、TypeScript 5.9、Vue 3、Vue Router 4、Axios、Naive UI、原生 SVG/Canvas(除非依赖评估证明需要新增图表库)、TDX JSON-RPC。
**Spec:** `docs/superpowers/specs/2026-08-24-index-sector-detail-design.md`
## Global Constraints
- Node 使用 `22.x`,MySQL 仅保留 `5.7.x` 兼容约束,排序规则为 `utf8mb4_general_ci`。
- 详情接口只读 TDX,不启用 MySQL、Redis、RocketMQ、AI 或用户体系。
- TDX 原始地址、请求体、凭据和原始异常不得进入 Web 响应;错误必须分类并脱敏。
- API 响应 `asOf` 保持 UTC ISO,Web 统一按 `Asia/Shanghai` 展示。
- 每个详情请求最多返回 120 个 K 线点;`period` 仅接受已验证的 `1d`。
- 不提交密钥、密码、`.env`、生产数据或 `.gitnexus/`;继续使用项目级 Gitea。
- 修改代码前完成 GitNexus upstream impact;提交前运行 `gitnexus_detect_changes`。
- 中文 Conventional Commit,提交验证通过后立即推送远程。
## 文件边界
### 新增
- `server/apps/api/src/market-overview/detail/market-detail.dto.ts`:详情响应、报价、K 线点和可用视图 OpenAPI DTO。
- `server/apps/api/src/market-overview/detail/market-detail.mapper.ts`:指数/板块 K 线的安全字段映射、日期排序、数量裁剪和状态辅助。
- `server/apps/api/src/market-overview/detail/market-detail.service.ts`:详情查询、业务代码白名单、TDX 错误降级和指数/板块口径分流。
- `server/apps/api/src/market-overview/detail/market-detail.controller.ts`:两个详情路由、查询参数校验和 Swagger 声明。
- `server/apps/api/src/market-overview/detail/market-detail.service.test.ts`、`market-detail.mapper.test.ts`:服务与映射单元测试。
- `server/apps/api/test/market-detail.e2e-spec.ts`:HTTP 参数、响应状态和脱敏测试。
- `web/src/api/market-overview/detail.ts`:详情 API Axios 封装。
- `web/src/views/market-overview/detail/MarketDetailPage.vue`:共享详情页外壳和加载/错误/延迟状态。
- `web/src/views/market-overview/detail/MarketDetailChart.vue`:固定尺寸的轻量 SVG K 线图和不可用边界。
- `web/src/views/market-overview/detail/market-detail.ts`:前端详情响应类型适配与展示辅助。
- `web/src/views/market-overview/detail/MarketDetailPage.test.ts`、`MarketDetailChart.test.ts`:路由状态、时区和图表输入测试。
- `docs/features/FEAT-MO-004-index-sector-detail/`:PRD、需求、设计、计划、测试、回退、开发总结、GitNexus、Review 和 QA 证据。
### 修改
- `server/apps/api/src/market-overview/market-overview.module.ts`:注册详情 Controller/Service。
- `server/apps/api/src/market-overview/tdx/tdx.types.ts`:仅在探测确认后补充分时响应类型;日 K 线已有类型保持兼容。
- `web/src/app/router.ts`:新增 `/market-overview/indices/:code` 与 `/market-overview/sectors/:code` 路由。
- `web/src/views/market-overview/MarketOverviewPage.vue`:把指数/板块选择事件改为真实路由导航。
- `server/openapi/openapi.json`、`web/src/api/generated/schema.ts`:通过既有生成脚本更新契约,不手写漂移。
## 实施任务
### Task 1:建立功能档案、基线与影响分析
**Files:**
- Create: `docs/features/FEAT-MO-004-index-sector-detail/manifest.yaml`
- Create: `docs/features/FEAT-MO-004-index-sector-detail/prd.md`
- Create: `docs/features/FEAT-MO-004-index-sector-detail/requirements.md`
- Create: `docs/features/FEAT-MO-004-index-sector-detail/design.md`
- Create: `docs/features/FEAT-MO-004-index-sector-detail/rollback.md`
- Create: `docs/features/FEAT-MO-004-index-sector-detail/gitnexus/impact-plan.md`
- [ ] 运行 `npm run schema:baseline`、`npm run schema:verify`;如果开发库为空,记录 `captured_empty`,不得伪造 SQL。
- [ ] 运行 `npm run governance:start -- FEAT-MO-004 index-sector-detail`,确认基线已提交、已推送且 GitNexus up-to-date。
- [ ] 对 `MarketOverviewController`、`MarketOverviewService`、`createTdxClient`、`createAppRouter` 和 `MarketOverviewPage` 执行 upstream impact,记录直接调用方、执行流、模块影响和风险。
- [ ] 将 HTTP/OpenAPI、Web API、消息、数据库、Redis、Scheduler、Worker、部署、测试和文档的同步判断写入 `gitnexus/impact-plan.md`。
### Task 2:TDX 能力探测和脱敏证据
**Files:**
- Create: `docs/features/FEAT-MO-004-index-sector-detail/tdx-capability-probe.md`
- Test: `server/apps/api/src/market-overview/tdx/tdx.client.test.ts`(只补充固定方法/参数边界测试,如确有必要)
- [ ] 使用当前开发环境 TDX 隧道探测 `get_market_data` 的日 K 线、候选分时方法、时间字段、响应上限和一级行业代码历史 K 线。
- [ ] 记录方法名、参数形状、返回字段、耗时、是否可重复和脱敏失败原因;禁止把地址、请求体凭据或原始异常写入文档。
- [ ] 若分时协议或字段完整性未通过,只登记 `intraday=false`,不改造协议、不生成假数据。
### Task 3:先写详情 Mapper 失败测试并实现安全 K 线映射
**Files:**
- Create: `server/apps/api/src/market-overview/detail/market-detail.mapper.test.ts`
- Create: `server/apps/api/src/market-overview/detail/market-detail.mapper.ts`
- Create: `server/apps/api/src/market-overview/detail/market-detail.dto.ts`
**Interfaces:**
- `mapDetailSeries(series: TdxKlineSeries, count: number): DetailSeriesPoint[]`
- `mapDetailQuote(series: TdxKlineSeries, definition: DetailInstrumentDefinition): DetailQuoteDto | null`
- `trimAndSortSeries(points: DetailSeriesPoint[], count: number): DetailSeriesPoint[]`
- [ ] 为空序列、日期非法、字段长度不一致、倒序日期、缺少金额/成交量、超过 120 条和相邻收盘价为零编写失败测试。
- [ ] 实现只读取 `Date/Open/High/Low/Close/Amount/Volume`,按合法交易日期升序排序,单点字段缺失时返回 `null` 或降级状态,不抛出原始数据。
- [ ] 实现价格、涨跌幅和方向计算;板块报价只暴露设计中确认的强弱字段。
- [ ] 运行 mapper 定向测试并确认通过。
### Task 4:实现详情 Service、Controller 和 OpenAPI DTO
**Files:**
- Create: `server/apps/api/src/market-overview/detail/market-detail.service.ts`
- Create: `server/apps/api/src/market-overview/detail/market-detail.controller.ts`
- Create: `server/apps/api/src/market-overview/detail/market-detail.service.test.ts`
- Modify: `server/apps/api/src/market-overview/market-overview.module.ts`
**Interfaces:**
- `MarketDetailService.getIndexDetail(code: string, period: DetailPeriod, count: number): Promise<IndexDetailResponseDto>`
- `MarketDetailService.getSectorDetail(code: string, period: DetailPeriod, count: number): Promise<SectorDetailResponseDto>`
- `GET /api/v1/market-overview/indices/:code/detail?period=1d&count=120`
- `GET /api/v1/market-overview/sectors/:code/detail?period=1d&count=120`
- [ ] 先写成功、非法代码、非法 period/count、TDX 超时、协议错误、空序列、部分字段缺失和延迟交易日测试。
- [ ] Controller 只接受白名单指数/一级行业代码、`period=1d` 和 `1..120` 的整数 count;非法参数由 Nest 标准 400 返回。
- [ ] Service 调用固定 `get_market_data`,不把方法名或原始 params 从 HTTP 传入;指数和板块使用独立定义表和 warning 文案。
- [ ] 以 `fresh/delayed/partial/unavailable` 生成元数据;详情失败只影响本次请求,不影响首页接口。
- [ ] 注册模块并生成 OpenAPI;运行 API 单元测试和 E2E 定向测试。
### Task 5:接入 Web 路由、Axios API 和共享详情页面
**Files:**
- Create: `web/src/api/market-overview/detail.ts`
- Create: `web/src/views/market-overview/detail/market-detail.ts`
- Create: `web/src/views/market-overview/detail/MarketDetailPage.vue`
- Create: `web/src/views/market-overview/detail/MarketDetailChart.vue`
- Create: `web/src/views/market-overview/detail/MarketDetailPage.test.ts`
- Create: `web/src/views/market-overview/detail/MarketDetailChart.test.ts`
- Modify: `web/src/app/router.ts`
- Modify: `web/src/views/market-overview/MarketOverviewPage.vue`
- Modify: `web/src/styles/index.scss`
**Interfaces:**
- `createMarketDetailApi(requestClient): { getIndexDetail(code, params), getSectorDetail(code, params) }`
- `MarketDetailPage` consumes route `type/code`, calls only the Axios API wrapper, and renders `instrument/quote/series/availableViews`.
- [ ] 先写路由参数、加载、空、错误、延迟、上海时区和 390px 视口测试。
- [ ] 实现页面固定结构:返回概览、名称/代码/来源/更新时间、核心行情、K 线、数据质量和事实型复盘摘要。
- [ ] 实现轻量 SVG 图表,固定容器尺寸,点数多于 120 时不额外扩张;`intraday=false` 只显示边界说明。
- [ ] 修改概览点击事件使用 `router.push`,不在页面直接调用 Axios 或 TDX。
- [ ] 运行 Web 定向测试、typecheck 和生产构建;确认移动端无横向溢出。
### Task 6:契约、全量验证与 Review
**Files:**
- Modify: `server/openapi/openapi.json`
- Modify: `web/src/api/generated/schema.ts`
- Create: `docs/features/FEAT-MO-004-index-sector-detail/test-plan.md`
- Create: `docs/features/FEAT-MO-004-index-sector-detail/implementation-plan.md`
- Create: `docs/features/FEAT-MO-004-index-sector-detail/development-summary.md`
- Create: `docs/features/FEAT-MO-004-index-sector-detail/review/code-review.md`
- Create: `docs/features/FEAT-MO-004-index-sector-detail/review/standards-review.md`
- Create: `docs/features/FEAT-MO-004-index-sector-detail/qa/test-results.md`
- Create: `docs/features/FEAT-MO-004-index-sector-detail/qa/visual-qa.md`
- Create: `docs/features/FEAT-MO-004-index-sector-detail/gitnexus/after.md`
- Create: `docs/features/FEAT-MO-004-index-sector-detail/gitnexus/detected-changes.md`
- Create: `docs/features/FEAT-MO-004-index-sector-detail/gitnexus/comparison.md`
- [ ] 运行 `npm run check:contracts`,确认后端 DTO、OpenAPI 和 Web 类型一致。
- [ ] 运行定向测试、`npm run verify`、开发服务器 Playwright 桌面/390px 截图和非空图表检查。
- [ ] 执行代码 Review、开发规范符合性 Review,记录发现、处置和残余风险。
- [ ] 运行 `npm run governance:finish -- FEAT-MO-004 index-sector-detail`,刷新 GitNexus 并保存前后对比。
- [ ] 运行 `gitnexus_detect_changes`,确认只影响详情模块、路由、契约和预期文档。
- [ ] 使用中文 Conventional Commit 提交并立即推送项目 Gitea;推送失败不得标记完成。
## 计划自检
- 设计覆盖:指数/板块双切片、TDX 能力门、状态降级、路由、契约、时区、安全、性能、测试、回退均有对应任务。
- 占位检查:没有以 TBD/TODO 或“适当处理”代替具体行为;分时未验证时明确返回 `intraday=false`。
- 类型一致性:Service 参数、DTO 字段、Web API 方法和页面消费字段与设计文档一致。
@@ -0,0 +1,398 @@
# 市场结构与成交额趋势实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 为综合概览增加行业/题材排行、资金流向排行和全市场成交额趋势,并保持现有指数、市场宽度和短线情绪模块的独立失败隔离。
**Architecture:** 在现有 `market-overview` 模块内增加一个连续可读的 `structure` 业务边界。TDX 负责已验证的指数与成交额能力,AKTools 只通过固定白名单适配行业、题材和资金接口;Nest 输出三个独立 HTTP API,Vue 通过 Axios 和 composable 并行消费,任何未验证的数据源都返回明确状态而不生成占位数据。
**Tech Stack:** NestJS、TypeScript、Vue 3、Axios、TDX HTTP 桥接、AKTools HTTP、OpenAPI/`openapi-typescript`、Vitest/Node test、Playwright。
**Spec:** `docs/superpowers/specs/2026-08-25-market-structure-and-turnover-design.md`
## Global Constraints
- Node 使用 `22.x` 系列;MySQL 使用 `5.7.x`,本切片不启用 MySQL、Redis、RocketMQ。
- 数据库若被治理命令读取,只允许 `APP_ENV=development` 的 `a_share_analysis_dev`,字符集 `utf8mb4`、排序规则 `utf8mb4_general_ci`。
- 浏览器不得直连 TDX 或 AKTools;所有请求经过 `web/src/utils/request.ts` 和 Nest 服务端固定适配器。
- TDX/AKTools 地址、账号、密钥、家庭网络信息、原始行情载荷不得进入仓库、日志或 HTTP 响应。
- API 参数必须白名单校验;排序、分类、数量和时间粒度拒绝未知值及超出范围值。
- 成交额统一以亿元输出;资金净流入只能使用上游明确字段,不得从涨跌或成交额推导。
- 模块状态只能使用 `fresh`、`delayed`、`partial`、`unavailable`;不可用时禁止离线样本和伪造值。
- 修改任何函数、类、方法前先运行 GitNexus 影响分析;提交前运行 `gitnexus_detect_changes`。
- 每次提交使用带功能编号的中文 Conventional Commit,并立即推送当前分支到项目 Gitea。
### Task 1: 治理基线与数据源能力探针
**Files:**
- Create: `docs/features/FEAT-MO-005-market-structure-turnover/manifest.yaml`
- Create: `docs/features/FEAT-MO-005-market-structure-turnover/prd.md`
- Create: `docs/features/FEAT-MO-005-market-structure-turnover/requirements.md`
- Create: `docs/features/FEAT-MO-005-market-structure-turnover/design.md`
- Create: `docs/features/FEAT-MO-005-market-structure-turnover/test-plan.md`
- Create: `docs/features/FEAT-MO-005-market-structure-turnover/rollback.md`
- Create: `docs/features/FEAT-MO-005-market-structure-turnover/data-source-probe.md`
- Create: `tools/probes/market-structure-capability.mjs`
**Interfaces:**
- Consumes: local TDX bridge configuration and `http://127.0.0.1:8080/api/public/{interface}`.
- Produces: a redacted capability table consumed by Tasks 2–4; each endpoint is classified as `usable`, `partial`, or `unavailable`.
- [ ] **Step 1: Capture the development schema baseline before starting the version.**
Run:
```bash
npm run schema:baseline
npm run schema:verify
```
Expected: the commands either capture a valid development-only baseline or produce the repository-approved `not_applicable`/`captured_empty` evidence; no production host is contacted.
- [ ] **Step 2: Start the governance record and refresh GitNexus.**
Run:
```bash
npm run governance:start -- FEAT-MO-005 market-structure-turnover
```
Expected: `docs/features/FEAT-MO-005-market-structure-turnover/gitnexus/before.md` and `impact-plan.md` exist and record the baseline commit, schema evidence and current index.
- [ ] **Step 3: Implement the probe as a read-only script.**
The script must call these fixed candidates and output only status, elapsed time, row count, keys, first/last timestamp and a short error class:
```js
const candidates = [
'stock_board_industry_spot_em',
'stock_board_concept_spot_em',
'stock_sector_fund_flow_rank',
'stock_market_fund_flow',
'stock_zh_a_spot_em',
];
```
For TDX, call `get_market_data` for `000001.SH`, `399001.SZ`, and `899050.BJ` with `period` values `1m`, `5m`, `15m`, and `1d`; report whether `Date` and `Amount` are aligned and whether timestamps are intraday.
- [ ] **Step 4: Run the probe twice after market close and once during a trading session.**
Run:
```bash
node tools/probes/market-structure-capability.mjs --base-url http://127.0.0.1:8080
```
Expected: the report distinguishes “收盘后最后一条记录” from actual盘中 freshness, records HTTP 500/timeout separately, and does not store raw rows or URLs beyond the configured local base URL.
- [ ] **Step 5: Decide source mapping in the feature record.**
Write one of these explicit outcomes for each metric: TDX primary, AKTools supplement, derived from validated snapshots, or deferred as unavailable. Do not begin Tasks 2–4 until the mapping and field definitions are recorded.
- [ ] **Step 6: Commit the probe and governance documents.**
```bash
git add docs/features/FEAT-MO-005-market-structure-turnover tools/probes/market-structure-capability.mjs
git commit -m "docs(FEAT-MO-005): 建立市场结构数据源探针"
git push origin HEAD
```
### Task 2: 建立结构领域类型、映射和来源客户端
**Files:**
- Create: `server/apps/api/src/market-overview/structure/market-structure.types.ts`
- Create: `server/apps/api/src/market-overview/structure/market-structure.mapper.ts`
- Create: `server/apps/api/src/market-overview/structure/market-structure.mapper.test.ts`
- Create: `server/apps/api/src/market-overview/structure/aktools-structure.client.ts`
- Create: `server/apps/api/src/market-overview/structure/aktools-structure.client.test.ts`
- Modify: `server/apps/api/src/market-overview/market-overview.dto.ts`
- Modify: `server/apps/api/src/market-overview/tdx/tdx.types.ts` only if the probe confirms an intraday TDX response shape.
**Interfaces:**
- Consumes: Task 1 source mapping and raw TDX/AKTools rows.
- Produces: `SectorRankingRecord`, `CapitalFlowRecord`, `TurnoverPoint`, `MarketStructureSourceClient`, and pure mappers that reject invalid dates, negative amounts, duplicate timestamps and unknown categories.
- [ ] **Step 1: Run symbol-level GitNexus impact before editing shared DTOs.**
Run impact analysis for `MarketOverviewMetaDto`, `MarketOverviewService`, and `createAktoolsClient` with upstream direction. Record direct callers and affected flows in `gitnexus/impact-plan.md`; if risk is HIGH or CRITICAL, stop and obtain explicit scope confirmation.
- [ ] **Step 2: Write failing mapper tests.**
Cover these exact cases:
```ts
expect(mapSectorRanking({ category: 'industry', name: '半导体', changePercent: '2.5' })).toMatchObject({ changePercent: 2.5 });
expect(mapSectorRanking({ category: 'unknown', name: 'X' })).toBeNull();
expect(mapTurnoverPoints([{ time: '2026-08-25 09:35', amount: '10' }, { time: '2026-08-25 09:30', amount: '8' }])).toEqual({ points: [], invalidCount: 2 });
expect(mapCapitalFlow({ netInflow: 'N/A' }).netInflow).toBeNull();
```
- [ ] **Step 3: Implement source clients with fixed endpoint allowlists.**
`AktoolsStructureClient` may call only endpoints classified usable by Task 1. It must apply the existing timeout, response-size limit, JSON validation and error redaction pattern. It must not accept an arbitrary interface name from a request query.
- [ ] **Step 4: Implement pure mappers and source-specific unit tests.**
Convert provider field names to domain fields, normalize amounts to亿元, preserve `source`, `tradeDate` and `asOf`, and return `null` for unknown money definitions instead of zero.
- [ ] **Step 5: Run focused tests and commit.**
```bash
pnpm --filter @a-share-analysis/server test -- market-structure.mapper aktools-structure.client
git add server/apps/api/src/market-overview/structure server/apps/api/src/market-overview/market-overview.dto.ts
git commit -m "feat(FEAT-MO-005): 建立市场结构数据映射边界"
git push origin HEAD
```
### Task 3: 实现行业、题材和板块排行 API
**Files:**
- Create: `server/apps/api/src/market-overview/structure/market-structure.dto.ts`
- Create: `server/apps/api/src/market-overview/structure/market-structure.service.ts`
- Create: `server/apps/api/src/market-overview/structure/market-structure.controller.ts`
- Create: `server/apps/api/src/market-overview/structure/market-structure.service.test.ts`
- Modify: `server/apps/api/src/market-overview/market-overview.module.ts`
**Interfaces:**
- Consumes: `MarketStructureSourceClient` and Task 2 mappers.
- Produces: `GET /api/v1/market-overview/sector-rankings?category=industry|concept|board&sort=rise|fall|turnover&limit=1..100`.
- [ ] **Step 1: Add controller validation tests for category, sort and limit.**
Reject `category=stock`, `sort=unknown`, `limit=0`, `limit=101` and non-integer limits with a 400 response; accept `industry`, `concept`, `board`, `rise`, `fall`, `turnover` and limits 1–100.
- [ ] **Step 2: Add service tests for sorting and status.**
Verify descending rise/fall/turnover order, top-N truncation, `partial` when one category source fails, `unavailable` when all rows fail, and `delayed` when the latest trade date is not the current Shanghai trading date.
- [ ] **Step 3: Implement DTOs and service.**
Return `category`, `code`, `name`, `changePercent`, `turnoverAmount`, `advancingCount`, `decliningCount`, `leader`, and `tradeDate`; omit fields that the source cannot substantiate rather than filling zeros.
- [ ] **Step 4: Register the structure controller/provider and run the server suite.**
```bash
pnpm --filter @a-share-analysis/server test
pnpm --filter @a-share-analysis/server typecheck
```
- [ ] **Step 5: Commit the endpoint.**
```bash
git add server/apps/api/src/market-overview
git commit -m "feat(FEAT-MO-005): 增加行业题材板块排行接口"
git push origin HEAD
```
### Task 4: 实现资金流向 API
**Files:**
- Modify: `server/apps/api/src/market-overview/structure/market-structure.dto.ts`
- Modify: `server/apps/api/src/market-overview/structure/market-structure.service.ts`
- Modify: `server/apps/api/src/market-overview/structure/market-structure.controller.ts`
- Modify: `server/apps/api/src/market-overview/structure/market-structure.service.test.ts`
**Interfaces:**
- Consumes: Task 1 confirmed fund-flow endpoint and Task 2 `CapitalFlowRecord`.
- Produces: `GET /api/v1/market-overview/capital-flow?category=industry|concept|board&sort=inflow|outflow&limit=1..100`.
- [ ] **Step 1: Add failing tests for explicit money definitions.**
Assert that a row without a documented net-flow field returns `netInflow=null`, `flowDefinition='unavailable'`; assert that the API never derives net flow from `changePercent` or `turnoverAmount`.
- [ ] **Step 2: Implement field mapping and sorting.**
Sort only validated numeric fields, preserve `null` at the end, and include the provider definition text in `flowDefinition`.
- [ ] **Step 3: Add partial and unavailable behavior tests.**
One failed category request must preserve other category data with `partial`; all failed requests return `data=null` and `unavailable`.
- [ ] **Step 4: Run focused server tests and commit.**
```bash
pnpm --filter @a-share-analysis/server test -- market-structure.service
git add server/apps/api/src/market-overview/structure
git commit -m "feat(FEAT-MO-005): 增加行业题材资金流向接口"
git push origin HEAD
```
### Task 5: 实现全市场成交额趋势 API
**Files:**
- Modify: `server/apps/api/src/market-overview/structure/market-structure.dto.ts`
- Modify: `server/apps/api/src/market-overview/structure/market-structure.service.ts`
- Modify: `server/apps/api/src/market-overview/structure/market-structure.controller.ts`
- Modify: `server/apps/api/src/market-overview/structure/market-structure.service.test.ts`
- Modify: `server/apps/api/src/market-overview/tdx/tdx.types.ts` and `server/apps/api/src/market-overview/tdx/tdx.mapper.ts` only when Task 1 confirms TDX intraday Amount.
**Interfaces:**
- Consumes: validated TDX intraday Amount or the explicitly approved AKTools fallback.
- Produces: `GET /api/v1/market-overview/turnover-trend?interval=5|15&tradeDate=YYYYMMDD` with `TurnoverPoint[]`.
- [ ] **Step 1: Add failing tests for point integrity.**
Cover aligned timestamps, 5/15-minute interval validation, cumulative amount calculation, negative values, duplicate timestamps, date mismatch, and close-after-market handling.
- [ ] **Step 2: Implement trend mapping.**
Sort points by time, reject invalid points, calculate `cumulativeAmount` from accepted interval amounts, and report `invalidPointCount` through a warning rather than silently repairing data.
- [ ] **Step 3: Implement API status rules.**
Return `fresh` only when the latest point is within the validated trading-session freshness window; after close, use the latest trading date and expose the final timestamp without calling it delayed solely because the market is closed.
- [ ] **Step 4: Run server tests and commit.**
```bash
pnpm --filter @a-share-analysis/server test -- market-structure
git add server/apps/api/src/market-overview
git commit -m "feat(FEAT-MO-005): 增加全市场成交额趋势接口"
git push origin HEAD
```
### Task 6: 生成契约并接入前端数据层
**Files:**
- Modify: `server/openapi/openapi.json`
- Modify: `web/src/api/generated/schema.ts`
- Create: `web/src/api/market-overview/structure.ts`
- Modify: `web/src/api/market-overview/index.ts`
- Modify: `web/src/types/market-overview.ts`
- Modify: `web/src/views/market-overview/useMarketOverview.ts`
- Test: `web/src/api/market-overview/structure.test.ts`
- Test: `web/src/views/market-overview/useMarketOverview.test.ts`
**Interfaces:**
- Consumes: Tasks 3–5 OpenAPI routes and DTOs.
- Produces: typed `getSectorRankings`, `getCapitalFlow`, `getTurnoverTrend` methods and composable states with independent loading/error handling.
- [ ] **Step 1: Regenerate contracts instead of hand-editing generated files.**
```bash
pnpm check:contracts
```
Expected: OpenAPI and generated TypeScript schema match; boundary verification passes.
- [ ] **Step 2: Add Axios API wrappers.**
Each wrapper must call `/api/v1/market-overview/...` through `request.ts`, pass only typed query parameters, and never expose the AKTools base URL.
- [ ] **Step 3: Extend `useMarketOverview`.**
Add independent `sectorRankings`, `capitalFlow`, and `turnoverTrend` states; include them in `isLoading`, `setLoading`, and `refresh` without coupling their errors to existing `sectors` or `funds` states.
- [ ] **Step 4: Add API and composable tests.**
Assert query serialization, typed response assignment, parallel loading, and one-module failure isolation.
- [ ] **Step 5: Commit the contract/data-layer change.**
```bash
git add server/openapi web/src/api web/src/types web/src/views/market-overview/useMarketOverview.ts
git commit -m "feat(FEAT-MO-005): 接入市场结构前端数据层"
git push origin HEAD
```
### Task 7: 实现首屏排行与成交额趋势组件
**Files:**
- Create: `web/src/views/market-overview/components/SectorRankingPanel.vue`
- Create: `web/src/views/market-overview/components/CapitalFlowPanel.vue`
- Create: `web/src/views/market-overview/components/TurnoverTrendPanel.vue`
- Modify: `web/src/views/market-overview/MarketOverviewPage.vue`
- Modify: `web/src/styles/index.scss`
- Test: `web/src/views/market-overview/components/MarketStructurePanels.test.ts`
**Interfaces:**
- Consumes: Task 6 composable states and generated response types.
- Produces: scan-friendly category tabs, sortable rankings, click-through detail events, and a responsive amount trend chart.
- [ ] **Step 1: Write component tests before implementation.**
Cover loading, empty, unavailable, partial and delayed states; category switching; rise/fall/inflow/outflow sorting controls; `亿元` labels; and no rendering of unsupported null money values as zero.
- [ ] **Step 2: Implement ranking panels.**
Show a bounded top/bottom list on the overview, provide an accessible “查看全部” command, and emit a detail event only for validated `code` and `category` values.
- [ ] **Step 3: Implement the turnover trend chart.**
Use the existing lightweight chart conventions in the repository; draw interval amount and cumulative amount with stable dimensions, visible units and no SVG/network dependency that introduces a new package.
- [ ] **Step 4: Integrate panels without nesting cards.**
Keep the page sections full-width within the existing overview grid, preserve the current index/breadth/short-term panels, and avoid a single card containing multiple framed cards.
- [ ] **Step 5: Run front-end tests and typecheck.**
```bash
pnpm --filter @a-share-analysis/web test
pnpm --filter @a-share-analysis/web typecheck
```
- [ ] **Step 6: Commit the UI change.**
```bash
git add web/src/views/market-overview
git commit -m "feat(FEAT-MO-005): 展示市场结构与成交额趋势"
git push origin HEAD
```
### Task 8: 端到端验证、Review 与治理收尾
**Files:**
- Modify: `docs/features/FEAT-MO-005-market-structure-turnover/test-plan.md`
- Create: `docs/features/FEAT-MO-005-market-structure-turnover/review/code-review.md`
- Create: `docs/features/FEAT-MO-005-market-structure-turnover/review/standards-review.md`
- Create: `docs/features/FEAT-MO-005-market-structure-turnover/development-summary.md`
- Create: `docs/features/FEAT-MO-005-market-structure-turnover/release-notes.md`
- Create: `docs/features/FEAT-MO-005-market-structure-turnover/qa-report.md`
- Create: `docs/features/FEAT-MO-005-market-structure-turnover/gitnexus/after.md`
**Interfaces:**
- Consumes: all implementation tasks, probe report and generated contracts.
- Produces: repeatable verification evidence, two passed Reviews, final GitNexus comparison and a releasable feature record.
- [ ] **Step 1: Run focused and full verification.**
```bash
pnpm lint
pnpm typecheck
pnpm test
pnpm build
pnpm verify:e2e
```
- [ ] **Step 2: Verify the HTTP contract and real service states.**
During one trading session and once after close, call the three new routes and record status, row count, trade date, last timestamp, warning count and response duration without saving raw payloads.
- [ ] **Step 3: Run visual verification.**
Use Playwright at desktop and 390px mobile widths. Assert the page is nonblank, no page-level horizontal overflow exists, ranking text fits, and the trend chart has nonzero SVG/canvas pixels.
- [ ] **Step 4: Run GitNexus final detection and refresh.**
```bash
npx gitnexus analyze
npx gitnexus status
```
Run `gitnexus_detect_changes({ scope: "staged" })`, compare affected flows with `gitnexus/before.md`, and record any unexpected module impact before Review.
- [ ] **Step 5: Complete both Reviews.**
Code Review must inspect source allowlists, status rules, field口径, error isolation, performance and tests. Standards Review must inspect Chinese comments, API folder boundaries, security evidence, performance evidence, documentation traceability and commit/push history. Both documents must end with `passed` only after issues are resolved.
- [ ] **Step 6: Finish governance and push the final record.**
```bash
npm run governance:finish -- FEAT-MO-005 market-structure-turnover
git add docs/features/FEAT-MO-005-market-structure-turnover
git commit -m "docs(FEAT-MO-005): 完成市场结构功能治理收尾"
git push origin HEAD
```
Expected: feature record is complete, final GitNexus report is saved, and no required task remains undocumented.
@@ -0,0 +1,159 @@
# 市场结构与成交额趋势设计
- 关联功能:FEAT-MO-005
- 设计状态:待用户审阅
- 日期:2026-08-25
## 1. 目标
把综合概览的重点从可选分时图调整为盘面结构总结,优先回答:市场强弱如何、行业和题材资金及涨跌方向如何、全市场成交额是在放大还是收缩。
本切片继续采用 Nest 服务端聚合、Vue 通过 Axios 访问业务 API 的边界。TDX 保持指数、市场宽度和基础行情主链路;AKTools/AKShare 只作为行业、题材和资金流向的候选补充源,必须先通过实际能力探针。
## 2. 用户范围
### 首屏展示
1. 市场结论和主要指数。
2. 上涨、下跌、平盘、涨停、跌停、炸板等市场宽度与短线情绪。
3. 行业和题材的涨跌排行。
4. 行业和题材资金净流入、净流出排行。
5. 全市场成交额趋势图和累计成交额。
6. 每个模块的交易日期、最后更新时间、数据源和 `fresh/delayed/partial/unavailable` 状态。
### 交互范围
- 首屏每个排行模块只展示重点名次,并提供“查看全部”入口。
- 排行支持行业/题材切换,以及涨幅、跌幅、成交额、净流入、净流出排序。
- 点击行业或题材进入现有详情页体系;无法识别的 provider 原始分类不得生成错误详情链接。
- 成交额趋势支持盘中增量和累计值;收盘后保留完整交易日曲线。
## 3. 明确不在本切片范围
- 不把分时 K 线作为首页核心视图;详情页分时仍保持独立能力验证状态。
- 不提供交易指令、荐股、目标价、仓位建议或自动下单。
- 不启用用户体系、MySQL、Redis、RocketMQ 或 AI 运行链路。
- 不把价格涨跌、成交额或成交量推导成主力净流入;资金净流入必须来自有明确字段口径的数据源。
- 不把单个行业指数代表全市场资金;全市场成交额与板块资金流必须分别标识。
## 4. 数据源与口径
### 4.1 TDX 主链路
- 指数、市场宽度、核心行情和已验证的日成交额继续使用 TDX。
- 当前已有的日成交额趋势只能作为收盘后及历史对比口径,不能直接冒充盘中 5/15 分钟趋势。
- 盘中成交额趋势必须先验证 TDX 是否能返回多市场、带时间戳的分时 Amount;若 TDX 不支持,再评估 AKTools 或快照聚合方案。
### 4.2 AKTools/AKShare 补充链路
优先探测以下能力:
- 行业板块涨跌幅和成交额排行。
- 概念题材涨跌幅和成交额排行。
- 行业/题材资金净流入和净流出排行。
- 全市场或指数的分时成交额字段。
探针必须记录接口参数、HTTP 状态、字段结构、交易日期、更新时间和重复请求稳定性;不保存原始载荷。探针失败时只记录 `unavailable`,不生成示例数据。
### 4.3 分类口径
- `industry`:供应商的行业板块分类。
- `concept`:供应商的概念/题材分类。
- `board`:仅在数据源明确提供独立板块分类时启用;不能把行业或概念重复映射成第三种分类。
- 每条排行记录必须带 `category`、`code`、`name`、`tradeDate`、`asOf` 和 `source`。
### 4.4 资金口径
- `netInflow` 只能使用上游明确提供的净流入字段。
- 无法确认主力、大单或统计方向时返回 `null` 并说明原因。
- 不同数据源的资金字段不在服务端直接求和;跨源比较只在字段口径相同且文档明确时允许。
### 4.5 成交额趋势口径
- 基本单位统一为亿元;服务端负责单位转换,前端不根据字段名猜单位。
- 每个点至少包含 `time`、`amount`、`cumulativeAmount`。
- `amount` 表示该时间区间成交额,`cumulativeAmount` 表示当日截至该点累计成交额。
- 缺少时间戳、金额为负数、时间倒序或区间重复时,该点被拒绝并将模块标记为 `partial`。
## 5. API 设计
继续使用统一响应外壳:
```json
{
"snapshotId": "20260825-101500",
"asOf": "2026-08-25T10:15:00+08:00",
"tradeDate": "20260825",
"source": "tdx",
"status": "fresh",
"data": {},
"warnings": []
}
```
新增或调整为以下独立接口,禁止把所有数据塞入一个首页接口:
```text
GET /api/market-overview/sector-rankings
GET /api/market-overview/capital-flow
GET /api/market-overview/turnover-trend
```
### `sector-rankings`
查询参数:`category=industry|concept|board`、`sort=rise|fall|turnover`、`limit=1..100`。
记录字段:`category`、`code`、`name`、`changePercent`、`turnoverAmount`、`advancingCount`、`decliningCount`、`leader`、`tradeDate`。
### `capital-flow`
查询参数:`category=industry|concept|board`、`sort=inflow|outflow`、`limit=1..100`。
记录字段:`category`、`code`、`name`、`netInflow`、`netOutflow`、`turnoverAmount`、`tradeDate`、`flowDefinition`。
### `turnover-trend`
查询参数:`interval=5|15`、`tradeDate=YYYYMMDD`(默认最近可用交易日)。
记录字段:`time`、`amount`、`cumulativeAmount`;响应额外返回 `unit=亿元`、`marketCount` 和 `granularity`。
所有参数使用白名单和范围校验;上游地址、原始异常和完整载荷不得进入响应。
## 6. 前端结构
```text
web/src/api/market-overview/index.ts
web/src/views/market-overview/
├── components/MarketSummaryPanel.vue
├── components/MarketBreadthPanel.vue
├── components/SectorRankingPanel.vue
├── components/CapitalFlowPanel.vue
└── components/TurnoverTrendPanel.vue
```
组件只消费已生成的契约类型,不直连 AKTools/TDX。排行表格优先保证扫描效率,避免把全部分类堆叠在首屏;窄屏时改为分类标签和横向可滚动表格,不能出现页面级横向溢出。
## 7. 失败、时效和安全
- 四类状态沿用 `fresh`、`delayed`、`partial`、`unavailable`。
- 单个上游接口失败只影响对应模块;其他模块继续展示。
- 收盘后的最后记录不自动判定为延迟;必须结合上海交易时段和交易日期判断。
- AKTools 仍只允许服务端固定白名单调用,设置超时、响应体上限、字段校验和错误脱敏。
- 不保存行情原始载荷,不记录家庭网络地址、TDX 凭据或 AKTools 凭据。
## 8. 性能与验证目标
- 排行接口只返回请求的 `limit` 条记录;服务端拒绝超过 100 条的单次请求。
- 首页模块并行请求,单个模块超时不阻塞首屏。
- 成交额趋势默认最多返回一个交易日的 5 分钟点,必要时再支持 15 分钟降采样。
- 单测覆盖字段映射、单位转换、排序、分类白名单、资金口径缺失、时间倒序、重复点和部分数据。
- 真实联调至少覆盖交易时段一次和收盘后一次,分别记录最后时间、成功率、缺口率和状态判断。
- 完成 HTTP/OpenAPI、前端状态、构建、Playwright 桌面/窄屏、代码 Review 和规范 Review。
## 9. 分阶段实施
1. 数据源能力探针:先确认行业/题材排行、资金流和盘中成交额接口。
2. 服务端适配器与 DTO:固定数据源边界,先输出独立 API。
3. 前端排行和成交额趋势组件:接入模块状态和点击详情。
4. 盘中与收盘后联调:校验时间口径和数据质量。
5. 完成治理档案、Review、GitNexus 前后对比和 Gitea 推送。
@@ -0,0 +1,80 @@
import { BadRequestException, Controller, Get, Inject, Param, Query } from '@nestjs/common';
import { ApiOkResponse, ApiParam, ApiQuery, ApiTags } from '@nestjs/swagger';
import {
IndexDetailResponseDto,
SectorDetailResponseDto,
type DetailPeriod,
} from './market-detail.dto';
import { MarketDetailService } from './market-detail.service';
type DetailQuery = { period?: string; count?: string };
function parseDetailQuery(query: DetailQuery): { period: DetailPeriod; count: number } {
const period = query.period ?? '1d';
if (period !== '1d') throw new BadRequestException('只支持 1d 周期');
const count = query.count === undefined ? 120 : Number(query.count);
if (
!/^\d+$/.test(query.count ?? '120') ||
!Number.isSafeInteger(count) ||
count < 1 ||
count > 120
) {
throw new BadRequestException('count 必须是 1 到 120 的整数');
}
return { period, count };
}
function assertCodeShape(code: string): void {
if (!/^\d{6}\.(SH|SZ|BJ)$/.test(code)) {
throw new BadRequestException('代码格式不可用');
}
}
@ApiTags('market-overview-detail')
@Controller('market-overview')
export class MarketDetailController {
constructor(@Inject(MarketDetailService) private readonly service: MarketDetailService) {}
@Get('indices/:code/detail')
@ApiParam({ name: 'code', description: '带市场后缀的指数代码', example: '000001.SH' })
@ApiQuery({ name: 'period', required: false, enum: ['1d'], example: '1d' })
@ApiQuery({
name: 'count',
required: false,
type: Number,
minimum: 1,
maximum: 120,
example: 120,
})
@ApiOkResponse({ type: IndexDetailResponseDto })
getIndexDetail(
@Param('code') code: string,
@Query() query: DetailQuery,
): Promise<IndexDetailResponseDto> {
assertCodeShape(code);
const parsed = parseDetailQuery(query);
return this.service.getIndexDetail(code, parsed.period, parsed.count);
}
@Get('sectors/:code/detail')
@ApiParam({ name: 'code', description: '带市场后缀的一级行业代码', example: '881001.SH' })
@ApiQuery({ name: 'period', required: false, enum: ['1d'], example: '1d' })
@ApiQuery({
name: 'count',
required: false,
type: Number,
minimum: 1,
maximum: 120,
example: 120,
})
@ApiOkResponse({ type: SectorDetailResponseDto })
getSectorDetail(
@Param('code') code: string,
@Query() query: DetailQuery,
): Promise<SectorDetailResponseDto> {
assertCodeShape(code);
const parsed = parseDetailQuery(query);
return this.service.getSectorDetail(code, parsed.period, parsed.count);
}
}
@@ -0,0 +1,100 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { MarketOverviewMetaDto, type QuoteDirection } from '../market-overview.dto';
export type DetailInstrumentType = 'index' | 'sector';
export type DetailPeriod = '1d';
export class DetailInstrumentDto {
@ApiProperty({ type: String })
code!: string;
@ApiProperty({ type: String })
name!: string;
@ApiProperty({ enum: ['index', 'sector'] })
type!: DetailInstrumentType;
}
export class DetailQuoteDto {
@ApiProperty({ type: Number })
price!: number;
@ApiPropertyOptional({ type: Number, nullable: true })
changePercent!: number | null;
@ApiProperty({ enum: ['up', 'down', 'flat'] })
direction!: QuoteDirection;
@ApiProperty({ type: String, example: '20260818' })
tradeDate!: string;
}
export class DetailSeriesPointDto {
@ApiProperty({ type: String, example: '20260818' })
time!: string;
@ApiProperty({ type: Number })
open!: number;
@ApiProperty({ type: Number })
high!: number;
@ApiProperty({ type: Number })
low!: number;
@ApiProperty({ type: Number })
close!: number;
@ApiPropertyOptional({ type: Number, nullable: true })
amount!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true })
volume!: number | null;
}
export class DetailAvailableViewsDto {
@ApiProperty({ type: Boolean })
kline!: boolean;
@ApiProperty({ type: Boolean })
intraday!: boolean;
}
export class IndexDetailDataDto {
@ApiProperty({ type: () => DetailInstrumentDto })
instrument!: DetailInstrumentDto;
@ApiProperty({ type: () => DetailQuoteDto })
quote!: DetailQuoteDto;
@ApiProperty({ type: [DetailSeriesPointDto] })
series!: DetailSeriesPointDto[];
@ApiProperty({ type: () => DetailAvailableViewsDto })
availableViews!: DetailAvailableViewsDto;
}
export class SectorDetailDataDto {
@ApiProperty({ type: () => DetailInstrumentDto })
instrument!: DetailInstrumentDto;
@ApiProperty({ type: () => DetailQuoteDto })
quote!: DetailQuoteDto;
@ApiProperty({ type: [DetailSeriesPointDto] })
series!: DetailSeriesPointDto[];
@ApiProperty({ type: () => DetailAvailableViewsDto })
availableViews!: DetailAvailableViewsDto;
}
export class IndexDetailResponseDto extends MarketOverviewMetaDto {
@ApiProperty({ type: () => IndexDetailDataDto, nullable: true })
data!: IndexDetailDataDto | null;
}
export class SectorDetailResponseDto extends MarketOverviewMetaDto {
@ApiProperty({ type: () => SectorDetailDataDto, nullable: true })
data!: SectorDetailDataDto | null;
}
@@ -0,0 +1,129 @@
import { describe, expect, it } from 'vitest';
import type { TdxKlineSeries } from '../tdx/tdx.types';
import { mapDetailQuote, mapDetailSeries } from './market-detail.mapper';
function createSeries(overrides: Partial<TdxKlineSeries> = {}): TdxKlineSeries {
return {
Date: ['20260817', '20260818'],
Open: ['100', '102'],
High: ['103', '106'],
Low: ['99', '101'],
Close: ['102', '105'],
Amount: ['1000', '1200'],
Volume: ['100', '120'],
...overrides,
};
}
describe('市场详情 K 线映射', () => {
it('按交易日期升序返回有效点并保留可空成交字段', () => {
const series = createSeries({
Date: ['20260818', '20260817', 'bad-date'],
Open: ['102', '100', '1'],
High: ['106', '103', '1'],
Low: ['101', '99', '1'],
Close: ['105', '102', '1'],
Amount: ['', '1000', '1'],
Volume: ['120', '100', '1'],
});
expect(mapDetailSeries(series, 120)).toEqual([
{
time: '20260817',
open: 100,
high: 103,
low: 99,
close: 102,
amount: 1000,
volume: 100,
},
{
time: '20260818',
open: 102,
high: 106,
low: 101,
close: 105,
amount: null,
volume: 120,
},
]);
});
it('过滤价格字段缺失或高低价不自洽的行', () => {
const series = createSeries({
Date: ['20260816', '20260817', '20260818'],
Open: ['100', '', '102'],
High: ['103', '104', '101'],
Low: ['99', '100', '100'],
Close: ['102', '103', '105'],
Amount: ['900', '1000', '1200'],
Volume: ['90', '100', '120'],
});
expect(mapDetailSeries(series, 120)).toEqual([
{
time: '20260816',
open: 100,
high: 103,
low: 99,
close: 102,
amount: 900,
volume: 90,
},
]);
});
it('只保留请求数量内的最近交易日并把数量限制在 120', () => {
const dates = Array.from({ length: 130 }, (_, index) => {
const date = new Date(Date.UTC(2026, 0, index + 1));
return date.toISOString().slice(0, 10).replaceAll('-', '');
});
const values = dates.map((_, index) => String(index + 1));
const series = createSeries({
Date: dates,
Open: values,
High: values,
Low: values,
Close: values,
Amount: values,
Volume: values,
});
const points = mapDetailSeries(series, 999);
expect(points).toHaveLength(120);
expect(points[0]?.time).toBe('20260111');
expect(points.at(-1)?.time).toBe('20260510');
});
it('使用最后两个有效交易日计算最新报价和涨跌幅', () => {
expect(
mapDetailQuote(createSeries(), {
code: '000001.SH',
name: '上证指数',
type: 'index',
}),
).toEqual({
price: 105,
changePercent: 2.94,
direction: 'up',
tradeDate: '20260818',
});
});
it('没有相邻有效收盘价时保留价格但将涨跌幅置空', () => {
expect(
mapDetailQuote(createSeries({ Close: ['0', '105'] }), {
code: '881001.SH',
name: 'A股板块',
type: 'sector',
}),
).toEqual({
price: 105,
changePercent: null,
direction: 'flat',
tradeDate: '20260818',
});
});
});
@@ -0,0 +1,113 @@
import type { QuoteDirection } from '../market-overview.dto';
import type {
DetailInstrumentType,
DetailQuoteDto,
DetailSeriesPointDto,
} from './market-detail.dto';
import type { IndexDefinition, TdxKlineSeries } from '../tdx/tdx.types';
export type DetailInstrumentDefinition = IndexDefinition & { type: DetailInstrumentType };
const MAX_DETAIL_POINTS = 120;
function toNumber(value: string | undefined): number | null {
if (value === undefined || value.trim() === '') return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function toOptionalNonNegativeNumber(value: string | undefined): number | null {
const parsed = toNumber(value);
return parsed !== null && parsed >= 0 ? parsed : null;
}
function toTradeDate(value: string | undefined): string | null {
if (!value || !/^\d{8}$/.test(value)) return null;
const year = Number(value.slice(0, 4));
const month = Number(value.slice(4, 6));
const day = Number(value.slice(6, 8));
const date = new Date(0);
date.setUTCHours(0, 0, 0, 0);
date.setUTCFullYear(year, month - 1, day);
return year >= 1000 &&
date.getUTCFullYear() === year &&
date.getUTCMonth() === month - 1 &&
date.getUTCDate() === day
? value
: null;
}
function isValidOhlc(open: number, high: number, low: number, close: number): boolean {
return high >= Math.max(open, close, low) && low <= Math.min(open, close, high);
}
function mapPoint(series: TdxKlineSeries, index: number): DetailSeriesPointDto | null {
const time = toTradeDate(series.Date[index]);
const open = toNumber(series.Open[index]);
const high = toNumber(series.High[index]);
const low = toNumber(series.Low[index]);
const close = toNumber(series.Close[index]);
if (time === null || open === null || high === null || low === null || close === null) {
return null;
}
if (!isValidOhlc(open, high, low, close)) return null;
return {
time,
open,
high,
low,
close,
amount: toOptionalNonNegativeNumber(series.Amount[index]),
volume: toOptionalNonNegativeNumber(series.Volume[index]),
};
}
/** 只映射详情图表需要的字段,过滤坏点并按交易日稳定排序,防止原始数组污染页面。 */
export function mapDetailSeries(series: TdxKlineSeries, count: number): DetailSeriesPointDto[] {
const length = Math.min(
series.Date.length,
series.Open.length,
series.High.length,
series.Low.length,
series.Close.length,
);
const byDate = new Map<string, DetailSeriesPointDto>();
for (let index = 0; index < length; index += 1) {
const point = mapPoint(series, index);
if (point) byDate.set(point.time, point);
}
const safeCount = Number.isSafeInteger(count)
? Math.min(Math.max(count, 1), MAX_DETAIL_POINTS)
: MAX_DETAIL_POINTS;
return [...byDate.values()]
.sort((left, right) => left.time.localeCompare(right.time))
.slice(-safeCount);
}
function mapDirection(changePercent: number | null): QuoteDirection {
if (changePercent === null || changePercent === 0) return 'flat';
return changePercent > 0 ? 'up' : 'down';
}
/** 报价使用最后两个有效交易日;上一收盘为零时不计算涨跌幅,避免无意义的无穷值。 */
export function mapDetailQuote(
series: TdxKlineSeries,
definition: DetailInstrumentDefinition,
): DetailQuoteDto | null {
// 保留 definition 参数作为指数/板块共享映射边界;两类报价当前采用相同的收盘口径。
void definition;
const points = mapDetailSeries(series, MAX_DETAIL_POINTS);
const latest = points.at(-1);
if (!latest) return null;
const previous = points.at(-2);
const changePercent =
previous && previous.close > 0
? Math.round(((latest.close - previous.close) / previous.close) * 100 * 100) / 100
: null;
return {
price: latest.close,
changePercent,
direction: mapDirection(changePercent),
tradeDate: latest.time,
};
}
@@ -0,0 +1,134 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TdxClientError } from '../tdx/tdx.client';
import type { TdxClient } from '../tdx/tdx.client';
import { MarketDetailService } from './market-detail.service';
function createKline(overrides: Record<string, string[]> = {}) {
return {
Date: ['20260817', '20260818'],
Open: ['100', '102'],
High: ['103', '106'],
Low: ['99', '101'],
Close: ['102', '105'],
Amount: ['1000', '1200'],
Volume: ['100', '120'],
...overrides,
};
}
describe('市场详情服务', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-08-18T03:00:00.000Z'));
});
it('返回指数详情并限制 TDX 请求为固定周期和数量', async () => {
const client: TdxClient = {
request: vi.fn().mockResolvedValue({
ErrorId: '0',
Value: { '000001.SH': createKline() },
}),
};
const service = new MarketDetailService(client);
const response = await service.getIndexDetail('000001.SH', '1d', 120);
expect(response).toMatchObject({
source: 'tdx',
status: 'fresh',
data: {
instrument: { code: '000001.SH', name: '上证指数', type: 'index' },
quote: { price: 105, changePercent: 2.94, tradeDate: '20260818' },
availableViews: { kline: true, intraday: false },
},
});
expect(client.request).toHaveBeenCalledWith('get_market_data', {
stock_list: ['000001.SH'],
count: 120,
dividend_type: 'none',
period: '1d',
});
});
it('未知指数代码不触发 TDX 请求并返回不可用状态', async () => {
const client: TdxClient = { request: vi.fn() };
const response = await new MarketDetailService(client).getIndexDetail('999999.SH', '1d', 120);
expect(response.status).toBe('unavailable');
expect(response.data).toBeNull();
expect(response.warnings).toEqual(['指数代码不可用']);
expect(client.request).not.toHaveBeenCalled();
});
it('先校验一级行业目录,再读取板块详情', async () => {
const client: TdxClient = {
request: vi
.fn()
.mockResolvedValueOnce({ ErrorId: '0', Value: [{ Code: '881001.SH', Name: '行业总览' }] })
.mockResolvedValueOnce({ ErrorId: '0', Value: { '881001.SH': createKline() } }),
};
const response = await new MarketDetailService(client).getSectorDetail('881001.SH', '1d', 60);
expect(response).toMatchObject({
status: 'fresh',
data: { instrument: { code: '881001.SH', name: '行业总览', type: 'sector' } },
});
expect(client.request).toHaveBeenNthCalledWith(1, 'get_stock_list', {
market: '16',
list_type: 1,
});
expect(client.request).toHaveBeenNthCalledWith(2, 'get_market_data', {
stock_list: ['881001.SH'],
count: 60,
dividend_type: 'none',
period: '1d',
});
});
it('交易日期不是上海当前日期时标记 delayed 并保留数据', async () => {
const client: TdxClient = {
request: vi.fn().mockResolvedValue({
ErrorId: '0',
Value: { '000001.SH': createKline({ Date: ['20260815', '20260817'] }) },
}),
};
const response = await new MarketDetailService(client).getIndexDetail('000001.SH', '1d', 10);
expect(response.status).toBe('delayed');
expect(response.warnings).toEqual(['指数交易日期为 20260817,非当前日期']);
expect(response.data?.series).toHaveLength(2);
});
it('必填字段坏点会保留有效 K 线并标记 partial', async () => {
const client: TdxClient = {
request: vi.fn().mockResolvedValue({
ErrorId: '0',
Value: {
'000001.SH': createKline({ Open: ['100', ''], Close: ['102', '105'] }),
},
}),
};
const response = await new MarketDetailService(client).getIndexDetail('000001.SH', '1d', 120);
expect(response.status).toBe('partial');
expect(response.data?.series).toHaveLength(1);
expect(response.warnings).toEqual(['指数部分 K 线字段不可用']);
});
it('TDX 超时只返回固定脱敏文案', async () => {
const client: TdxClient = {
request: vi.fn().mockRejectedValue(new TdxClientError('timeout', '原始超时细节')),
};
const response = await new MarketDetailService(client).getIndexDetail('000001.SH', '1d', 120);
expect(response.status).toBe('unavailable');
expect(response.data).toBeNull();
expect(response.warnings).toEqual(['指数请求超时']);
expect(JSON.stringify(response)).not.toContain('原始超时细节');
});
});
@@ -0,0 +1,168 @@
import { Inject, Injectable } from '@nestjs/common';
import type { TdxClient } from '../tdx/tdx.client';
import { TdxClientError } from '../tdx/tdx.client';
import {
CORE_INDEXES,
type TdxKlineSeries,
type TdxMarketDataResult,
type TdxStockListResult,
} from '../tdx/tdx.types';
import { TDX_CLIENT_PORT, type MarketOverviewResponse } from '../market-overview.service';
import type { DetailInstrumentDefinition } from './market-detail.mapper';
import { mapDetailQuote, mapDetailSeries } from './market-detail.mapper';
import type {
DetailPeriod,
IndexDetailDataDto,
IndexDetailResponseDto,
SectorDetailDataDto,
SectorDetailResponseDto,
} from './market-detail.dto';
const MAX_DETAIL_POINTS = 120;
function createSnapshotMeta(): Pick<
MarketOverviewResponse<never>,
'snapshotId' | 'asOf' | 'source'
> {
const asOf = new Date().toISOString();
const snapshotId = `${asOf.slice(0, 10).replaceAll('-', '')}-${asOf.slice(11, 19).replaceAll(':', '')}`;
return { snapshotId, asOf, source: 'tdx' };
}
function getShanghaiCalendarDate(asOf: string): string {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(new Date(asOf));
const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
return `${values.year}${values.month}${values.day}`;
}
function normalizeCount(count: number): number {
return Number.isSafeInteger(count) ? Math.min(Math.max(count, 1), MAX_DETAIL_POINTS) : 120;
}
function createTradeDateWarning(subject: string, tradeDate: string, asOf: string): string | null {
return tradeDate === getShanghaiCalendarDate(asOf)
? null
: `${subject}交易日期为 ${tradeDate},非当前日期`;
}
function getSeries(result: TdxMarketDataResult, code: string): TdxKlineSeries | null {
const series = result.Value?.[code];
return series && typeof series === 'object' ? series : null;
}
@Injectable()
export class MarketDetailService {
constructor(@Inject(TDX_CLIENT_PORT) private readonly tdxClient: TdxClient) {}
async getIndexDetail(
code: string,
period: DetailPeriod,
count: number,
): Promise<IndexDetailResponseDto> {
const definition = CORE_INDEXES.find((item) => item.code === code);
if (!definition || period !== '1d') {
const meta = createSnapshotMeta();
return { ...meta, status: 'unavailable', data: null, warnings: ['指数代码不可用'] };
}
return this.loadDetail({ ...definition, type: 'index' }, period, count, '指数');
}
async getSectorDetail(
code: string,
period: DetailPeriod,
count: number,
): Promise<SectorDetailResponseDto> {
const meta = createSnapshotMeta();
if (period !== '1d')
return { ...meta, status: 'unavailable', data: null, warnings: ['板块查询周期不可用'] };
try {
const catalog = await this.tdxClient.request<TdxStockListResult>('get_stock_list', {
market: '16',
list_type: 1,
});
const definition = catalog.Value?.find((item) => item.Code === code);
if (!definition)
return { ...meta, status: 'unavailable', data: null, warnings: ['板块代码不可用'] };
return this.loadDetail(
{ code: definition.Code, name: definition.Name, type: 'sector' },
period,
count,
'板块',
meta,
);
} catch (error) {
return {
...meta,
status: 'unavailable',
data: null,
warnings: [this.toWarning(error, '板块')],
};
}
}
private async loadDetail<TData extends IndexDetailDataDto | SectorDetailDataDto>(
definition: DetailInstrumentDefinition,
period: DetailPeriod,
count: number,
subject: '指数' | '板块',
inheritedMeta?: Pick<MarketOverviewResponse<never>, 'snapshotId' | 'asOf' | 'source'>,
): Promise<MarketOverviewResponse<TData>> {
const meta = inheritedMeta ?? createSnapshotMeta();
const safeCount = normalizeCount(count);
try {
const result = await this.tdxClient.request<TdxMarketDataResult>('get_market_data', {
stock_list: [definition.code],
count: safeCount,
dividend_type: 'none',
period,
});
const rawSeries = getSeries(result, definition.code);
const series = rawSeries ? mapDetailSeries(rawSeries, safeCount) : [];
const quote = rawSeries ? mapDetailQuote(rawSeries, definition) : null;
if (!rawSeries || !series.length || !quote) {
return { ...meta, status: 'unavailable', data: null, warnings: [`${subject}行情不可用`] };
}
const expectedRows = Math.min(safeCount, rawSeries.Date.length);
const isPartial = series.length < expectedRows;
const tradeDateWarning = createTradeDateWarning(subject, quote.tradeDate, meta.asOf);
const status = isPartial ? 'partial' : tradeDateWarning ? 'delayed' : 'fresh';
const data = {
instrument: { code: definition.code, name: definition.name, type: definition.type },
quote,
series,
// 分时候选协议尚未通过能力探测,禁止在页面伪造该视图。
availableViews: { kline: true, intraday: false },
} as TData;
return {
...meta,
status,
data,
warnings: isPartial
? [`${subject}部分 K 线字段不可用`]
: tradeDateWarning
? [tradeDateWarning]
: [],
};
} catch (error) {
return {
...meta,
status: 'unavailable',
data: null,
warnings: [this.toWarning(error, subject)],
};
}
}
private toWarning(error: unknown, subject: string): string {
if (error instanceof TdxClientError && error.kind === 'timeout') return `${subject}请求超时`;
if (error instanceof TdxClientError && error.kind === 'network') return `${subject}网络不可用`;
return `${subject}数据不可用`;
}
}
@@ -2,6 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export type MarketOverviewStatus = 'fresh' | 'delayed' | 'partial' | 'unavailable'; export type MarketOverviewStatus = 'fresh' | 'delayed' | 'partial' | 'unavailable';
export type QuoteDirection = 'up' | 'down' | 'flat'; export type QuoteDirection = 'up' | 'down' | 'flat';
export type MarketOverviewSource = 'tdx' | 'aktools' | 'mixed';
export class MarketOverviewMetaDto { export class MarketOverviewMetaDto {
@ApiProperty({ type: String }) @ApiProperty({ type: String })
@@ -10,8 +11,8 @@ export class MarketOverviewMetaDto {
@ApiProperty({ type: String, format: 'date-time' }) @ApiProperty({ type: String, format: 'date-time' })
asOf!: string; asOf!: string;
@ApiProperty({ type: String, enum: ['tdx'] }) @ApiProperty({ type: String, enum: ['tdx', 'aktools', 'mixed'] })
source!: 'tdx'; source!: MarketOverviewSource;
@ApiProperty({ enum: ['fresh', 'delayed', 'partial', 'unavailable'] }) @ApiProperty({ enum: ['fresh', 'delayed', 'partial', 'unavailable'] })
status!: MarketOverviewStatus; status!: MarketOverviewStatus;
@@ -7,6 +7,14 @@ import { ShortTermMarketController } from './short-term.controller';
import { AKTOOLS_CLIENT_PORT, ShortTermMarketService } from './short-term.service'; import { AKTOOLS_CLIENT_PORT, ShortTermMarketService } from './short-term.service';
import { createAktoolsClient } from './aktools/aktools.client'; import { createAktoolsClient } from './aktools/aktools.client';
import { createTdxClient } from './tdx/tdx.client'; import { createTdxClient } from './tdx/tdx.client';
import { MarketDetailController } from './detail/market-detail.controller';
import { MarketDetailService } from './detail/market-detail.service';
import { createAktoolsStructureClient } from './structure/aktools-structure.client';
import { MarketStructureController } from './structure/market-structure.controller';
import {
MARKET_STRUCTURE_SOURCE_PORT,
MarketStructureService,
} from './structure/market-structure.service';
@Module({}) @Module({})
export class MarketOverviewModule { export class MarketOverviewModule {
@@ -14,12 +22,23 @@ export class MarketOverviewModule {
static register(config: ApiRuntimeConfig): DynamicModule { static register(config: ApiRuntimeConfig): DynamicModule {
return { return {
module: MarketOverviewModule, module: MarketOverviewModule,
controllers: [MarketOverviewController, ShortTermMarketController], controllers: [
MarketOverviewController,
MarketDetailController,
ShortTermMarketController,
MarketStructureController,
],
providers: [ providers: [
MarketOverviewService, MarketOverviewService,
MarketDetailService,
MarketStructureService,
{ provide: TDX_CLIENT_PORT, useValue: createTdxClient(config.tdx) }, { provide: TDX_CLIENT_PORT, useValue: createTdxClient(config.tdx) },
ShortTermMarketService, ShortTermMarketService,
{ provide: AKTOOLS_CLIENT_PORT, useValue: createAktoolsClient(config.aktools) }, { provide: AKTOOLS_CLIENT_PORT, useValue: createAktoolsClient(config.aktools) },
{
provide: MARKET_STRUCTURE_SOURCE_PORT,
useValue: createAktoolsStructureClient(config.aktools),
},
], ],
}; };
} }
@@ -0,0 +1,45 @@
import { describe, expect, it, vi } from 'vitest';
import { createAktoolsStructureClient } from './aktools-structure.client';
describe('AKTools 市场结构客户端', () => {
it('只调用固定的行业接口并解析数组', async () => {
const fetchImpl = vi
.fn<typeof fetch>()
.mockResolvedValue(new Response(JSON.stringify([{ 名称: '半导体' }]), { status: 200 }));
const client = createAktoolsStructureClient({
enabled: true,
baseUrl: 'http://127.0.0.1:8080',
timeoutMs: 1000,
fetchImpl,
});
await expect(client.getSectorRows('industry')).resolves.toEqual([{ 名称: '半导体' }]);
expect(fetchImpl).toHaveBeenCalledWith(
'http://127.0.0.1:8080/api/public/stock_board_industry_spot_em',
expect.objectContaining({ method: 'GET' }),
);
});
it('不为未验证的 board 分类拼接任意接口', async () => {
const client = createAktoolsStructureClient({
enabled: true,
baseUrl: 'http://127.0.0.1:8080',
timeoutMs: 1000,
fetchImpl: vi.fn(),
});
await expect(client.getSectorRows('board')).rejects.toMatchObject({ kind: 'unavailable' });
});
it('服务关闭时不发起请求', async () => {
const fetchImpl = vi.fn<typeof fetch>();
const client = createAktoolsStructureClient({
enabled: false,
baseUrl: 'http://127.0.0.1:8080',
timeoutMs: 1000,
fetchImpl,
});
await expect(client.getCapitalFlowRows('industry')).rejects.toMatchObject({ kind: 'disabled' });
expect(fetchImpl).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,100 @@
import type { AktoolsRuntimeConfig } from '../../../../../libs/config/src/runtime-config';
import { AktoolsClientError } from '../aktools/aktools.client';
import type {
MarketStructureSourceClient,
StructureCategory,
StructureRawRow,
} from './market-structure.types';
export interface AktoolsStructureClientOptions extends AktoolsRuntimeConfig {
fetchImpl?: typeof fetch;
maxResponseBytes?: number;
}
const DEFAULT_MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
const ENDPOINTS = Object.freeze({
industry: 'stock_board_industry_spot_em',
concept: 'stock_board_concept_spot_em',
capitalFlow: 'stock_sector_fund_flow_rank',
marketFundFlow: 'stock_market_fund_flow',
});
function isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === 'AbortError';
}
function categoryEndpoint(category: StructureCategory): string {
if (category === 'industry') return ENDPOINTS.industry;
if (category === 'concept') return ENDPOINTS.concept;
throw new AktoolsClientError('unavailable', '板块分类数据源未验证');
}
function categoryFlowType(category: StructureCategory): string {
if (category === 'industry') return '行业资金流';
if (category === 'concept') return '概念资金流';
throw new AktoolsClientError('unavailable', '板块资金流数据源未验证');
}
export function createAktoolsStructureClient({
enabled,
baseUrl,
timeoutMs,
fetchImpl = fetch,
maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES,
}: AktoolsStructureClientOptions): MarketStructureSourceClient {
const endpoint = `${baseUrl.replace(/\/$/, '')}/api/public/`;
async function request(
path: string,
params: Readonly<Record<string, string>> = {},
): Promise<StructureRawRow[]> {
if (!enabled) throw new AktoolsClientError('disabled', 'AKTools 未启用');
const search = new URLSearchParams(params);
const url = `${endpoint}${path}${search.toString() ? `?${search.toString()}` : ''}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetchImpl(url, {
method: 'GET',
headers: { accept: 'application/json' },
signal: controller.signal,
});
const contentLength = response.headers.get('content-length');
if (contentLength && Number(contentLength) > maxResponseBytes) {
throw new AktoolsClientError('protocol', 'AKTools 响应体超过安全上限');
}
const text = await response.text();
if (Buffer.byteLength(text, 'utf8') > maxResponseBytes) {
throw new AktoolsClientError('protocol', 'AKTools 响应体超过安全上限');
}
if (!response.ok) throw new AktoolsClientError('unavailable', 'AKTools 服务不可用');
let value: unknown;
try {
value = JSON.parse(text);
} catch {
throw new AktoolsClientError('protocol', 'AKTools 返回格式错误');
}
if (!Array.isArray(value) || value.some((item) => !item || typeof item !== 'object')) {
throw new AktoolsClientError('protocol', 'AKTools 返回数据结构错误');
}
return value as StructureRawRow[];
} catch (error) {
if (error instanceof AktoolsClientError) throw error;
if (isAbortError(error)) throw new AktoolsClientError('timeout', 'AKTools 请求超时');
throw new AktoolsClientError('network', 'AKTools 网络请求失败');
} finally {
clearTimeout(timeout);
}
}
return {
getSectorRows: async (category) => request(categoryEndpoint(category)),
getCapitalFlowRows: async (category) =>
request(ENDPOINTS.capitalFlow, {
indicator: '今日',
sector_type: categoryFlowType(category),
}),
getMarketFundFlowRows: () => request(ENDPOINTS.marketFundFlow),
};
}
@@ -0,0 +1,69 @@
import { BadRequestException, Controller, Get, Inject, Query } from '@nestjs/common';
import { ApiOkResponse, ApiQuery, ApiTags } from '@nestjs/swagger';
import {
CapitalFlowResponseDto,
SectorRankingsResponseDto,
TurnoverTrendResponseDto,
} from './market-structure.dto';
import {
MarketStructureService,
parseCapitalFlowQuery,
parseSectorRankingQuery,
parseTurnoverTrendQuery,
} from './market-structure.service';
@ApiTags('market-overview-structure')
@Controller('market-overview')
export class MarketStructureController {
constructor(@Inject(MarketStructureService) private readonly service: MarketStructureService) {}
@Get('sector-rankings')
@ApiQuery({ name: 'category', required: false, enum: ['industry', 'concept', 'board'] })
@ApiQuery({ name: 'sort', required: false, enum: ['rise', 'fall', 'turnover'] })
@ApiQuery({ name: 'limit', required: false, type: Number, minimum: 1, maximum: 100 })
@ApiOkResponse({ type: SectorRankingsResponseDto })
getSectorRankings(
@Query('category') category?: string,
@Query('sort') sort?: string,
@Query('limit') limit?: string,
): Promise<SectorRankingsResponseDto> {
try {
return this.service.getSectorRankings(parseSectorRankingQuery(category, sort, limit));
} catch (error) {
throw new BadRequestException(error instanceof Error ? error.message : '排行参数无效');
}
}
@Get('capital-flow')
@ApiQuery({ name: 'category', required: false, enum: ['industry', 'concept', 'board'] })
@ApiQuery({ name: 'sort', required: false, enum: ['inflow', 'outflow'] })
@ApiQuery({ name: 'limit', required: false, type: Number, minimum: 1, maximum: 100 })
@ApiOkResponse({ type: CapitalFlowResponseDto })
getCapitalFlow(
@Query('category') category?: string,
@Query('sort') sort?: string,
@Query('limit') limit?: string,
): Promise<CapitalFlowResponseDto> {
try {
return this.service.getCapitalFlow(parseCapitalFlowQuery(category, sort, limit));
} catch (error) {
throw new BadRequestException(error instanceof Error ? error.message : '资金流参数无效');
}
}
@Get('turnover-trend')
@ApiQuery({ name: 'interval', required: false, enum: ['5', '15'] })
@ApiQuery({ name: 'tradeDate', required: false, type: String, pattern: '^\\d{8}$' })
@ApiOkResponse({ type: TurnoverTrendResponseDto })
getTurnoverTrend(
@Query('interval') interval?: string,
@Query('tradeDate') tradeDate?: string,
): Promise<TurnoverTrendResponseDto> {
try {
return this.service.getTurnoverTrend(parseTurnoverTrendQuery(interval, tradeDate));
} catch (error) {
throw new BadRequestException(error instanceof Error ? error.message : '成交额趋势参数无效');
}
}
}
@@ -0,0 +1,124 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { MarketOverviewMetaDto } from '../market-overview.dto';
import type { StructureCategory, StructureSort } from './market-structure.types';
export class SectorRankingItemDto {
@ApiProperty({ enum: ['industry', 'concept', 'board'] })
category!: StructureCategory;
@ApiProperty({ type: String })
code!: string;
@ApiProperty({ type: String })
name!: string;
@ApiPropertyOptional({ type: Number, nullable: true })
changePercent!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true, description: '亿元' })
turnoverAmount!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true })
advancingCount!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true })
decliningCount!: number | null;
@ApiPropertyOptional({ type: String, nullable: true })
leader!: string | null;
@ApiPropertyOptional({ type: String, nullable: true, example: '20260825' })
tradeDate!: string | null;
}
export class SectorRankingsDataDto {
@ApiProperty({ type: [SectorRankingItemDto] })
items!: SectorRankingItemDto[];
@ApiProperty({ enum: ['rise', 'fall', 'turnover'] })
sort!: Extract<StructureSort, 'rise' | 'fall' | 'turnover'>;
@ApiProperty({ enum: ['industry', 'concept', 'board'] })
category!: StructureCategory;
}
export class SectorRankingsResponseDto extends MarketOverviewMetaDto {
@ApiProperty({ type: () => SectorRankingsDataDto, nullable: true })
data!: SectorRankingsDataDto | null;
}
export class CapitalFlowItemDto {
@ApiProperty({ enum: ['industry', 'concept', 'board'] })
category!: StructureCategory;
@ApiPropertyOptional({ type: String, nullable: true })
code!: string | null;
@ApiProperty({ type: String })
name!: string;
@ApiPropertyOptional({ type: Number, nullable: true, description: '亿元' })
netInflow!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true, description: '亿元' })
netOutflow!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true, description: '亿元' })
turnoverAmount!: number | null;
@ApiPropertyOptional({ type: String, nullable: true })
tradeDate!: string | null;
@ApiProperty({ type: String })
flowDefinition!: string;
}
export class CapitalFlowDataDto {
@ApiProperty({ type: [CapitalFlowItemDto] })
items!: CapitalFlowItemDto[];
@ApiProperty({ enum: ['inflow', 'outflow'] })
sort!: Extract<StructureSort, 'inflow' | 'outflow'>;
@ApiProperty({ enum: ['industry', 'concept', 'board'] })
category!: StructureCategory;
}
export class CapitalFlowResponseDto extends MarketOverviewMetaDto {
@ApiProperty({ type: () => CapitalFlowDataDto, nullable: true })
data!: CapitalFlowDataDto | null;
}
export class TurnoverPointDto {
@ApiProperty({ type: String, format: 'date-time' })
time!: string;
@ApiProperty({ type: Number, description: '该时间区间成交额,单位亿元' })
amount!: number;
@ApiProperty({ type: Number, description: '当日累计成交额,单位亿元' })
cumulativeAmount!: number;
}
export class TurnoverTrendDataDto {
@ApiProperty({ type: [TurnoverPointDto] })
points!: TurnoverPointDto[];
@ApiProperty({ enum: ['5', '15'] })
granularity!: '5' | '15';
@ApiProperty({ type: String, example: '亿元' })
unit!: '亿元';
@ApiProperty({ type: Number, minimum: 1, maximum: 3 })
marketCount!: number;
@ApiProperty({ type: Number })
invalidPointCount!: number;
}
export class TurnoverTrendResponseDto extends MarketOverviewMetaDto {
@ApiProperty({ type: () => TurnoverTrendDataDto, nullable: true })
data!: TurnoverTrendDataDto | null;
}
@@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest';
import { mapCapitalFlow, mapSectorRanking, mapTurnoverPoints } from './market-structure.mapper';
describe('市场结构数据映射', () => {
it('映射行业排行并保留涨跌幅与成交额', () => {
expect(
mapSectorRanking(
{
代码: 'BK0001',
名称: '半导体',
涨跌幅: '2.5',
成交额: '123456789',
上涨家数: '20',
下跌家数: '4',
领涨股: '测试股份',
},
'industry',
'20260825',
),
).toEqual({
category: 'industry',
code: 'BK0001',
name: '半导体',
changePercent: 2.5,
turnoverAmount: 1.23,
advancingCount: 20,
decliningCount: 4,
leader: '测试股份',
tradeDate: '20260825',
});
});
it('拒绝未知分类和缺少代码名称的排行记录', () => {
expect(mapSectorRanking({ code: 'BK0001', name: '测试' }, 'board', '20260825')).toMatchObject({
category: 'board',
code: 'BK0001',
});
expect(mapSectorRanking({ 名称: '无代码' }, 'industry', '20260825')).toBeNull();
});
it('只使用上游明确的净流入字段,不用成交额推导资金', () => {
expect(
mapCapitalFlow(
{
名称: '半导体',
'今日主力净流入-净额': '100000000',
成交额: '500000000',
},
'industry',
'20260825',
),
).toMatchObject({
name: '半导体',
netInflow: 1,
netOutflow: null,
turnoverAmount: 5,
flowDefinition: '今日主力净流入-净额',
});
expect(mapCapitalFlow({ 名称: '未知', 成交额: '100' }, 'industry', '20260825')).toMatchObject({
netInflow: null,
netOutflow: null,
flowDefinition: 'unavailable',
});
});
it('拒绝倒序、重复和负数成交额点并计算累计值', () => {
expect(
mapTurnoverPoints([
{ 时间: '2026-08-25 09:30:00', 成交额: '100000000' },
{ 时间: '2026-08-25 09:35:00', 成交额: '200000000' },
{ 时间: '2026-08-25 09:35:00', 成交额: '300000000' },
{ 时间: '2026-08-25 09:34:00', 成交额: '400000000' },
{ 时间: '2026-08-25 09:40:00', 成交额: '-100000000' },
]),
).toEqual({
points: [
{ time: '2026-08-25 09:30:00', amount: 1, cumulativeAmount: 1 },
{ time: '2026-08-25 09:35:00', amount: 2, cumulativeAmount: 3 },
],
invalidCount: 3,
});
});
});
@@ -0,0 +1,120 @@
import type {
CapitalFlowRecord,
SectorRankingRecord,
StructureCategory,
StructureRawRow,
TurnoverMappingResult,
TurnoverPoint,
} from './market-structure.types';
function toNumber(value: unknown): number | null {
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
if (typeof value !== 'string' || !value.trim()) return null;
const parsed = Number(value.replaceAll(',', ''));
return Number.isFinite(parsed) ? parsed : null;
}
function toNonNegativeNumber(value: unknown): number | null {
const parsed = toNumber(value);
return parsed !== null && parsed >= 0 ? parsed : null;
}
function toText(value: unknown): string | null {
if (typeof value !== 'string') return null;
const text = value.trim();
return text ? text : null;
}
function firstValue(row: StructureRawRow, keys: readonly string[]): unknown {
for (const key of keys) {
if (row[key] !== undefined) return row[key];
}
return undefined;
}
function amountInYi(value: unknown): number | null {
const parsed = toNonNegativeNumber(value);
return parsed === null ? null : Math.round((parsed / 100_000_000) * 100) / 100;
}
function dateValue(value: unknown): string | null {
const text = toText(value);
return text && /^\d{8}$/.test(text) ? text : text;
}
export function mapSectorRanking(
row: StructureRawRow,
category: StructureCategory,
tradeDate: string | null,
): SectorRankingRecord | null {
const code = toText(firstValue(row, ['代码', '板块代码', 'code', 'f12']));
const name = toText(firstValue(row, ['名称', '板块名称', 'name', 'f14']));
if (!code || !name) return null;
return {
category,
code,
name,
changePercent: toNumber(firstValue(row, ['涨跌幅', '今日涨跌幅', 'changePercent', 'f3'])),
turnoverAmount: amountInYi(firstValue(row, ['成交额', 'turnoverAmount', 'f48'])),
advancingCount: toNonNegativeNumber(firstValue(row, ['上涨家数', 'advancingCount'])),
decliningCount: toNonNegativeNumber(firstValue(row, ['下跌家数', 'decliningCount'])),
leader: toText(firstValue(row, ['领涨股', '主力净流入最大股', 'leader'])),
tradeDate: dateValue(tradeDate),
};
}
export function mapCapitalFlow(
row: StructureRawRow,
category: StructureCategory,
tradeDate: string | null,
): CapitalFlowRecord {
const definitionKeys = ['今日主力净流入-净额', '主力净流入-净额', 'netInflow'] as const;
const netInflowRaw = firstValue(row, definitionKeys);
const netInflow = toNumber(netInflowRaw);
const netOutflow = toNumber(firstValue(row, ['netOutflow', '净流出', '净流出额']));
const flowDefinition =
netInflowRaw !== undefined
? (definitionKeys.find((key) => row[key] !== undefined) ?? 'unknown')
: 'unavailable';
return {
category,
code: toText(firstValue(row, ['代码', '板块代码', 'code', 'f12'])),
name: toText(firstValue(row, ['名称', '板块名称', 'name', 'f14'])) ?? '未知板块',
netInflow: netInflow === null ? null : Math.round((netInflow / 100_000_000) * 100) / 100,
netOutflow: netOutflow === null ? null : Math.round((netOutflow / 100_000_000) * 100) / 100,
turnoverAmount: amountInYi(firstValue(row, ['成交额', 'turnoverAmount', 'f48'])),
tradeDate: dateValue(tradeDate),
flowDefinition,
};
}
function parseTime(row: StructureRawRow): string | null {
return toText(firstValue(row, ['时间', '日期时间', '时间戳', 'time', 'datetime', 'Date']));
}
export function mapTurnoverPoints(rows: readonly StructureRawRow[]): TurnoverMappingResult {
const points: TurnoverPoint[] = [];
const seen = new Set<string>();
let invalidCount = 0;
for (const row of rows) {
const time = parseTime(row);
const amount = amountInYi(firstValue(row, ['成交额', 'amount', 'Amount']));
if (
!time ||
amount === null ||
seen.has(time) ||
(points.length > 0 && time <= points.at(-1)!.time)
) {
invalidCount += 1;
continue;
}
seen.add(time);
const previous = points.at(-1)?.cumulativeAmount ?? 0;
points.push({ time, amount, cumulativeAmount: Math.round((previous + amount) * 100) / 100 });
}
return { points, invalidCount };
}
@@ -0,0 +1,156 @@
import { describe, expect, it } from 'vitest';
import {
MarketStructureService,
parseCapitalFlowQuery,
parseSectorRankingQuery,
parseTurnoverTrendQuery,
} from './market-structure.service';
import type { MarketStructureSourceClient } from './market-structure.types';
import type { TdxClient } from '../tdx/tdx.client';
const source: MarketStructureSourceClient = {
getSectorRows: async () => [
{ 代码: 'BK2', 名称: '弱势', 涨跌幅: '-2', 成交额: '200000000', 日期: '20260825' },
{ 代码: 'BK1', 名称: '强势', 涨跌幅: '5', 成交额: '100000000', 日期: '20260825' },
],
getCapitalFlowRows: async () => [
{ 代码: 'BK1', 名称: '强势', '今日主力净流入-净额': '200000000', 日期: '20260825' },
{ 代码: 'BK2', 名称: '弱势', '今日主力净流入-净额': '-100000000', 日期: '20260825' },
],
getMarketFundFlowRows: async () => [],
};
describe('行业题材排行服务', () => {
it('校验分类、排序和 1-100 数量范围', () => {
expect(parseSectorRankingQuery('concept', 'fall', '10')).toEqual({
category: 'concept',
sort: 'fall',
limit: 10,
});
expect(() => parseSectorRankingQuery('stock', 'rise', '10')).toThrow('分类参数无效');
expect(() => parseSectorRankingQuery('industry', 'unknown', '10')).toThrow('排序参数无效');
expect(() => parseSectorRankingQuery('industry', 'rise', '0')).toThrow('数量参数无效');
expect(() => parseSectorRankingQuery('industry', 'rise', '101')).toThrow('数量参数无效');
});
it('按涨幅排序并截取指定数量', async () => {
const service = new MarketStructureService(source);
const response = await service.getSectorRankings({
category: 'industry',
sort: 'rise',
limit: 1,
});
expect(response.status).toBe('fresh');
expect(response.data?.items).toHaveLength(1);
expect(response.data?.items[0]).toMatchObject({ code: 'BK1', changePercent: 5 });
});
it('上游失败时返回不可用且不泄露异常内容', async () => {
const failing: MarketStructureSourceClient = {
...source,
getSectorRows: async () => {
throw new Error('secret upstream URL and payload');
},
};
const service = new MarketStructureService(failing);
const response = await service.getSectorRankings({
category: 'industry',
sort: 'rise',
limit: 10,
});
expect(response).toMatchObject({ status: 'unavailable', data: null });
expect(response.warnings).toEqual(['行业排行数据不可用']);
});
it('校验资金流分类、排序和数量参数', () => {
expect(parseCapitalFlowQuery('concept', 'outflow', '20')).toEqual({
category: 'concept',
sort: 'outflow',
limit: 20,
});
expect(() => parseCapitalFlowQuery('stock', 'inflow', '10')).toThrow('分类参数无效');
expect(() => parseCapitalFlowQuery('industry', 'unknown', '10')).toThrow('排序参数无效');
expect(() => parseCapitalFlowQuery('industry', 'inflow', '101')).toThrow('数量参数无效');
});
it('按净流入排序并保留资金口径', async () => {
const service = new MarketStructureService(source);
const response = await service.getCapitalFlow({
category: 'industry',
sort: 'inflow',
limit: 10,
});
expect(response.status).toBe('fresh');
expect(response.data?.items[0]).toMatchObject({
code: 'BK1',
netInflow: 2,
flowDefinition: '今日主力净流入-净额',
});
});
it('缺少上游净流入字段时标记 partial,不填充零值', async () => {
const partial: MarketStructureSourceClient = {
...source,
getCapitalFlowRows: async () => [{ 代码: 'BK1', 名称: '未知', 成交额: '100000000' }],
};
const response = await new MarketStructureService(partial).getCapitalFlow({
category: 'industry',
sort: 'inflow',
limit: 10,
});
expect(response.status).toBe('partial');
expect(response.data?.items[0]).toMatchObject({
netInflow: null,
flowDefinition: 'unavailable',
});
});
it('校验成交额趋势粒度和交易日期', () => {
expect(parseTurnoverTrendQuery('15', '20260824')).toEqual({
interval: '15',
tradeDate: '20260824',
});
expect(() => parseTurnoverTrendQuery('1', '20260824')).toThrow('粒度参数无效');
expect(() => parseTurnoverTrendQuery('5', '20260231')).toThrow('交易日期参数无效');
});
it('聚合多个市场的 TDX 分时成交额并计算累计值', async () => {
const tdxClient: TdxClient = {
request: async <TResponse>() =>
({
ErrorId: '0',
Value: {
'000001.SH': {
Date: ['2026-08-24 09:30:00', '2026-08-24 09:35:00'],
Amount: ['10000', '20000'],
},
'399001.SZ': {
Date: ['2026-08-24 09:30:00', '2026-08-24 09:35:00'],
Amount: ['30000', '40000'],
},
},
}) as TResponse,
};
const response = await new MarketStructureService(source, tdxClient).getTurnoverTrend({
interval: '5',
tradeDate: '20260824',
});
expect(response.status).toBe('partial');
expect(response.data?.points).toEqual([
{ time: '2026-08-24 09:30:00', amount: 4, cumulativeAmount: 4 },
{ time: '2026-08-24 09:35:00', amount: 6, cumulativeAmount: 10 },
]);
});
it('TDX 分时序列为空时返回不可用', async () => {
const tdxClient: TdxClient = {
request: async <TResponse>() => ({ ErrorId: '0', Value: {} }) as TResponse,
};
const response = await new MarketStructureService(source, tdxClient).getTurnoverTrend({
interval: '5',
tradeDate: '20260824',
});
expect(response).toMatchObject({ status: 'unavailable', data: null });
});
});
@@ -0,0 +1,335 @@
import { Inject, Injectable, Optional } from '@nestjs/common';
import type { MarketOverviewStatus } from '../market-overview.dto';
import { TDX_CLIENT_PORT } from '../market-overview.service';
import type { TdxClient } from '../tdx/tdx.client';
import { TURNOVER_INDEXES, type TdxKlineSeries, type TdxMarketDataResult } from '../tdx/tdx.types';
import { mapCapitalFlow, mapSectorRanking } from './market-structure.mapper';
import { mapTurnoverPoints } from './market-structure.mapper';
import type {
CapitalFlowRecord,
SectorRankingRecord,
StructureCategory,
StructureSort,
MarketStructureSourceClient,
} from './market-structure.types';
import type {
CapitalFlowResponseDto,
SectorRankingsResponseDto,
TurnoverTrendResponseDto,
} from './market-structure.dto';
export const MARKET_STRUCTURE_SOURCE_PORT = Symbol('MARKET_STRUCTURE_SOURCE_PORT');
export interface SectorRankingQuery {
category: StructureCategory;
sort: Extract<StructureSort, 'rise' | 'fall' | 'turnover'>;
limit: number;
}
export interface CapitalFlowQuery {
category: StructureCategory;
sort: Extract<StructureSort, 'inflow' | 'outflow'>;
limit: number;
}
export interface TurnoverTrendQuery {
interval: '5' | '15';
tradeDate: string;
}
const CATEGORIES = new Set<StructureCategory>(['industry', 'concept', 'board']);
const SORTS = new Set<SectorRankingQuery['sort']>(['rise', 'fall', 'turnover']);
const FLOW_SORTS = new Set<CapitalFlowQuery['sort']>(['inflow', 'outflow']);
export function parseSectorRankingQuery(
category = 'industry',
sort = 'rise',
limit = '10',
): SectorRankingQuery {
if (!CATEGORIES.has(category as StructureCategory)) throw new Error('分类参数无效');
if (!SORTS.has(sort as SectorRankingQuery['sort'])) throw new Error('排序参数无效');
if (!/^\d+$/.test(limit)) throw new Error('数量参数无效');
const parsedLimit = Number(limit);
if (!Number.isSafeInteger(parsedLimit) || parsedLimit < 1 || parsedLimit > 100) {
throw new Error('数量参数无效');
}
return {
category: category as StructureCategory,
sort: sort as SectorRankingQuery['sort'],
limit: parsedLimit,
};
}
export function parseCapitalFlowQuery(
category = 'industry',
sort = 'inflow',
limit = '10',
): CapitalFlowQuery {
if (!CATEGORIES.has(category as StructureCategory)) throw new Error('分类参数无效');
if (!FLOW_SORTS.has(sort as CapitalFlowQuery['sort'])) throw new Error('排序参数无效');
if (!/^\d+$/.test(limit)) throw new Error('数量参数无效');
const parsedLimit = Number(limit);
if (!Number.isSafeInteger(parsedLimit) || parsedLimit < 1 || parsedLimit > 100) {
throw new Error('数量参数无效');
}
return {
category: category as StructureCategory,
sort: sort as CapitalFlowQuery['sort'],
limit: parsedLimit,
};
}
function isValidDate(value: string): boolean {
if (!/^\d{8}$/.test(value)) return false;
const date = new Date(`${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}T00:00:00Z`);
return (
date.getUTCFullYear() === Number(value.slice(0, 4)) &&
date.getUTCMonth() + 1 === Number(value.slice(4, 6)) &&
date.getUTCDate() === Number(value.slice(6, 8))
);
}
export function parseTurnoverTrendQuery(
interval = '5',
tradeDate = currentShanghaiDate(),
): TurnoverTrendQuery {
if (interval !== '5' && interval !== '15') throw new Error('粒度参数无效');
if (!isValidDate(tradeDate)) throw new Error('交易日期参数无效');
return { interval, tradeDate };
}
function currentShanghaiDate(now = new Date()): string {
const values = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(now);
const parts = Object.fromEntries(values.map((item) => [item.type, item.value]));
return `${parts.year}${parts.month}${parts.day}`;
}
function snapshotMeta(
source: 'aktools',
): Pick<SectorRankingsResponseDto, 'snapshotId' | 'asOf' | 'source'> {
const asOf = new Date().toISOString();
return {
snapshotId: asOf.replace(/[-:TZ.]/g, '').slice(0, 15),
asOf,
source,
};
}
function sortItems(
items: readonly SectorRankingRecord[],
sort: SectorRankingQuery['sort'],
): SectorRankingRecord[] {
const valueFor = (item: SectorRankingRecord): number => {
if (sort === 'turnover') return item.turnoverAmount ?? Number.NEGATIVE_INFINITY;
return item.changePercent ?? Number.NEGATIVE_INFINITY;
};
const direction = sort === 'fall' ? 1 : -1;
return [...items].sort((left, right) => {
const difference = (valueFor(left) - valueFor(right)) * direction;
return difference || left.name.localeCompare(right.name, 'zh-CN');
});
}
function statusFor(items: readonly SectorRankingRecord[], rawCount: number): MarketOverviewStatus {
if (!items.length) return 'unavailable';
if (items.length < rawCount || items.some((item) => item.tradeDate === null)) return 'partial';
return 'fresh';
}
function sortFlows(
items: readonly CapitalFlowRecord[],
sort: CapitalFlowQuery['sort'],
): CapitalFlowRecord[] {
return [...items].sort((left, right) => {
const leftValue = left.netInflow ?? Number.NEGATIVE_INFINITY;
const rightValue = right.netInflow ?? Number.NEGATIVE_INFINITY;
const difference = sort === 'inflow' ? rightValue - leftValue : leftValue - rightValue;
return difference || left.name.localeCompare(right.name, 'zh-CN');
});
}
function flowStatusFor(
items: readonly CapitalFlowRecord[],
rawCount: number,
): MarketOverviewStatus {
if (!items.length) return 'unavailable';
if (
items.length < rawCount ||
items.some((item) => item.flowDefinition === 'unavailable' || item.tradeDate === null)
) {
return 'partial';
}
return 'fresh';
}
function aggregateTdxSeries(
result: TdxMarketDataResult,
tradeDate: string,
): { rows: Array<{ 时间: string; 成交额: number }>; marketCount: number } {
const amountsByTime = new Map<string, number>();
let marketCount = 0;
for (const definition of TURNOVER_INDEXES) {
const series: TdxKlineSeries | undefined = result.Value?.[definition.code];
if (!series || !Array.isArray(series.Date) || !Array.isArray(series.Amount)) continue;
if (series.Date.length === 0 || series.Date.length !== series.Amount.length) continue;
const validRows = series.Date.flatMap((time, index) => {
if (
typeof time !== 'string' ||
!time.startsWith(
`${tradeDate.slice(0, 4)}-${tradeDate.slice(4, 6)}-${tradeDate.slice(6, 8)}`,
)
)
return [];
const amount = Number(series.Amount[index]);
return Number.isFinite(amount) && amount >= 0 ? [{ time, amount: amount * 10_000 }] : [];
});
if (!validRows.length) continue;
marketCount += 1;
for (const row of validRows)
amountsByTime.set(row.time, (amountsByTime.get(row.time) ?? 0) + row.amount);
}
return {
rows: [...amountsByTime.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([时间, 成交额]) => ({ 时间, 成交额 })),
marketCount,
};
}
@Injectable()
export class MarketStructureService {
constructor(
@Inject(MARKET_STRUCTURE_SOURCE_PORT)
private readonly source: MarketStructureSourceClient,
@Optional()
@Inject(TDX_CLIENT_PORT)
private readonly tdxClient: TdxClient = {
request: async <TResponse>() => ({}) as TResponse,
},
) {}
async getSectorRankings(query: SectorRankingQuery): Promise<SectorRankingsResponseDto> {
const meta = snapshotMeta('aktools');
try {
const rows = await this.source.getSectorRows(query.category);
const items = rows.flatMap((row) => {
const mapped = mapSectorRanking(row, query.category, this.readTradeDate(row));
return mapped ? [mapped] : [];
});
const sortedAll = sortItems(items, query.sort);
const sorted = sortedAll.slice(0, query.limit);
const status = statusFor(sortedAll, rows.length);
return {
...meta,
status,
data:
status === 'unavailable'
? null
: { items: sorted, sort: query.sort, category: query.category },
warnings:
status === 'fresh'
? []
: status === 'partial'
? ['部分行业或题材排行字段不可用']
: ['行业排行数据不可用'],
};
} catch {
return {
...meta,
status: 'unavailable',
data: null,
warnings: ['行业排行数据不可用'],
};
}
}
async getCapitalFlow(query: CapitalFlowQuery): Promise<CapitalFlowResponseDto> {
const meta = snapshotMeta('aktools');
try {
const rows = await this.source.getCapitalFlowRows(query.category);
const items = rows.map((row) => mapCapitalFlow(row, query.category, this.readTradeDate(row)));
const sortedAll = sortFlows(items, query.sort);
const sorted = sortedAll.slice(0, query.limit);
const status = flowStatusFor(sortedAll, rows.length);
return {
...meta,
status,
data:
status === 'unavailable'
? null
: { items: sorted, sort: query.sort, category: query.category },
warnings:
status === 'fresh'
? []
: status === 'partial'
? ['部分资金流向字段或交易日期不可用']
: ['资金流向数据不可用'],
};
} catch {
return {
...meta,
status: 'unavailable',
data: null,
warnings: ['资金流向数据不可用'],
};
}
}
async getTurnoverTrend(query: TurnoverTrendQuery): Promise<TurnoverTrendResponseDto> {
const asOf = new Date().toISOString();
const meta = {
snapshotId: asOf.replace(/[-:TZ.]/g, '').slice(0, 15),
asOf,
source: 'tdx' as const,
};
try {
const result = await this.tdxClient.request<TdxMarketDataResult>('get_market_data', {
stock_list: TURNOVER_INDEXES.map((item) => item.code),
count: 240,
dividend_type: 'none',
period: `${query.interval}m`,
});
const aggregated = aggregateTdxSeries(result, query.tradeDate);
const mapped = mapTurnoverPoints(aggregated.rows);
if (!mapped.points.length) {
return { ...meta, status: 'unavailable', data: null, warnings: ['成交额趋势数据不可用'] };
}
const dateStatus: MarketOverviewStatus =
query.tradeDate === currentShanghaiDate() ? 'fresh' : 'delayed';
const status: MarketOverviewStatus =
aggregated.marketCount < TURNOVER_INDEXES.length || mapped.invalidCount > 0
? 'partial'
: dateStatus;
return {
...meta,
status,
data: {
points: mapped.points,
granularity: query.interval,
unit: '亿元',
marketCount: aggregated.marketCount,
invalidPointCount: mapped.invalidCount,
},
warnings:
status === 'fresh'
? []
: status === 'partial'
? ['部分市场或成交额点不可用']
: [`成交额交易日期为 ${query.tradeDate},非当前日期`],
};
} catch {
return { ...meta, status: 'unavailable', data: null, warnings: ['成交额趋势数据不可用'] };
}
}
private readTradeDate(row: Readonly<Record<string, unknown>>): string | null {
const value = row['日期'] ?? row['交易日期'] ?? row['tradeDate'];
return typeof value === 'string' && /^\d{8}$/.test(value) ? value : null;
}
}
@@ -0,0 +1,45 @@
export type StructureCategory = 'industry' | 'concept' | 'board';
export type StructureSort = 'rise' | 'fall' | 'turnover' | 'inflow' | 'outflow';
export type StructureRawRow = Readonly<Record<string, unknown>>;
export interface SectorRankingRecord {
category: StructureCategory;
code: string;
name: string;
changePercent: number | null;
turnoverAmount: number | null;
advancingCount: number | null;
decliningCount: number | null;
leader: string | null;
tradeDate: string | null;
}
export interface CapitalFlowRecord {
category: StructureCategory;
code: string | null;
name: string;
netInflow: number | null;
netOutflow: number | null;
turnoverAmount: number | null;
tradeDate: string | null;
flowDefinition: string;
}
export interface TurnoverPoint {
time: string;
amount: number;
cumulativeAmount: number;
}
export interface TurnoverMappingResult {
points: TurnoverPoint[];
invalidCount: number;
}
export interface MarketStructureSourceClient {
getSectorRows(category: StructureCategory): Promise<StructureRawRow[]>;
getCapitalFlowRows(category: StructureCategory): Promise<StructureRawRow[]>;
getMarketFundFlowRows(): Promise<StructureRawRow[]>;
}
@@ -0,0 +1,120 @@
import type { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { MarketDetailController } from '../src/market-overview/detail/market-detail.controller';
import { MarketDetailService } from '../src/market-overview/detail/market-detail.service';
import { TDX_CLIENT_PORT } from '../src/market-overview/market-overview.service';
const indexResponse = {
ErrorId: '0',
Value: {
'000001.SH': {
Date: ['20260817', '20260818'],
Open: ['100', '102'],
High: ['103', '106'],
Low: ['99', '101'],
Close: ['102', '105'],
Amount: ['1000', '1200'],
Volume: ['100', '120'],
},
},
};
describe('市场详情 HTTP API', () => {
let app: INestApplication | undefined;
afterEach(async () => {
await app?.close();
app = undefined;
});
it('返回指数详情并隐藏 TDX 原始字段', async () => {
const client = { request: vi.fn().mockResolvedValue(indexResponse) };
const moduleRef = await Test.createTestingModule({
controllers: [MarketDetailController],
providers: [MarketDetailService, { provide: TDX_CLIENT_PORT, useValue: client }],
}).compile();
app = moduleRef.createNestApplication();
app.setGlobalPrefix('api/v1');
await app.init();
const response = await request(app.getHttpServer())
.get('/api/v1/market-overview/indices/000001.SH/detail?count=2')
.expect(200);
expect(response.body).toMatchObject({
source: 'tdx',
data: {
instrument: { code: '000001.SH', name: '上证指数', type: 'index' },
series: [{ time: '20260817' }, { time: '20260818' }],
availableViews: { kline: true, intraday: false },
},
});
expect(JSON.stringify(response.body)).not.toContain('ErrorId');
});
it('拒绝越界数量和非法代码且不调用 TDX', async () => {
const client = { request: vi.fn() };
const moduleRef = await Test.createTestingModule({
controllers: [MarketDetailController],
providers: [MarketDetailService, { provide: TDX_CLIENT_PORT, useValue: client }],
}).compile();
app = moduleRef.createNestApplication();
app.setGlobalPrefix('api/v1');
await app.init();
await request(app.getHttpServer())
.get('/api/v1/market-overview/indices/not-a-code/detail')
.expect(400);
await request(app.getHttpServer())
.get('/api/v1/market-overview/indices/000001.SH/detail?count=121')
.expect(400);
expect(client.request).not.toHaveBeenCalled();
});
it('板块详情先读目录再返回行业 K 线', async () => {
const client = {
request: vi
.fn()
.mockResolvedValueOnce({
ErrorId: '0',
Value: [{ Code: '881001.SH', Name: '行业总览' }],
})
.mockResolvedValueOnce({
ErrorId: '0',
Value: {
'881001.SH': {
Date: ['20260817', '20260818'],
Open: ['100', '102'],
High: ['103', '106'],
Low: ['99', '101'],
Close: ['102', '105'],
Amount: ['1000', '1200'],
Volume: ['100', '120'],
},
},
}),
};
const moduleRef = await Test.createTestingModule({
controllers: [MarketDetailController],
providers: [MarketDetailService, { provide: TDX_CLIENT_PORT, useValue: client }],
}).compile();
app = moduleRef.createNestApplication();
app.setGlobalPrefix('api/v1');
await app.init();
const response = await request(app.getHttpServer())
.get('/api/v1/market-overview/sectors/881001.SH/detail')
.expect(200);
expect(response.body.data.instrument).toEqual({
code: '881001.SH',
name: '行业总览',
type: 'sector',
});
expect(client.request).toHaveBeenCalledTimes(2);
});
});
+486 -5
View File
@@ -19,7 +19,70 @@
"type": "object" "type": "object"
}, },
"snapshotId": { "type": "string" }, "snapshotId": { "type": "string" },
"source": { "enum": ["tdx"], "type": "string" }, "source": { "enum": ["tdx", "aktools", "mixed"], "type": "string" },
"status": {
"enum": ["fresh", "delayed", "partial", "unavailable"],
"type": "string"
},
"warnings": { "items": { "type": "string" }, "type": "array" }
},
"required": ["snapshotId", "asOf", "source", "status", "warnings", "data"],
"type": "object"
},
"CapitalFlowDataDto": {
"properties": {
"category": {
"enum": ["industry", "concept", "board"],
"type": "string"
},
"items": {
"items": { "$ref": "#/components/schemas/CapitalFlowItemDto" },
"type": "array"
},
"sort": { "enum": ["inflow", "outflow"], "type": "string" }
},
"required": ["items", "sort", "category"],
"type": "object"
},
"CapitalFlowItemDto": {
"properties": {
"category": {
"enum": ["industry", "concept", "board"],
"type": "string"
},
"code": { "nullable": true, "type": "string" },
"flowDefinition": { "type": "string" },
"name": { "type": "string" },
"netInflow": {
"description": "亿元",
"nullable": true,
"type": "number"
},
"netOutflow": {
"description": "亿元",
"nullable": true,
"type": "number"
},
"tradeDate": { "nullable": true, "type": "string" },
"turnoverAmount": {
"description": "亿元",
"nullable": true,
"type": "number"
}
},
"required": ["category", "name", "flowDefinition"],
"type": "object"
},
"CapitalFlowResponseDto": {
"properties": {
"asOf": { "format": "date-time", "type": "string" },
"data": {
"allOf": [{ "$ref": "#/components/schemas/CapitalFlowDataDto" }],
"nullable": true,
"type": "object"
},
"snapshotId": { "type": "string" },
"source": { "enum": ["tdx", "aktools", "mixed"], "type": "string" },
"status": { "status": {
"enum": ["fresh", "delayed", "partial", "unavailable"], "enum": ["fresh", "delayed", "partial", "unavailable"],
"type": "string" "type": "string"
@@ -40,6 +103,46 @@
"required": ["status", "detail"], "required": ["status", "detail"],
"type": "object" "type": "object"
}, },
"DetailAvailableViewsDto": {
"properties": {
"intraday": { "type": "boolean" },
"kline": { "type": "boolean" }
},
"required": ["kline", "intraday"],
"type": "object"
},
"DetailInstrumentDto": {
"properties": {
"code": { "type": "string" },
"name": { "type": "string" },
"type": { "enum": ["index", "sector"], "type": "string" }
},
"required": ["code", "name", "type"],
"type": "object"
},
"DetailQuoteDto": {
"properties": {
"changePercent": { "nullable": true, "type": "number" },
"direction": { "enum": ["up", "down", "flat"], "type": "string" },
"price": { "type": "number" },
"tradeDate": { "example": "20260818", "type": "string" }
},
"required": ["price", "direction", "tradeDate"],
"type": "object"
},
"DetailSeriesPointDto": {
"properties": {
"amount": { "nullable": true, "type": "number" },
"close": { "type": "number" },
"high": { "type": "number" },
"low": { "type": "number" },
"open": { "type": "number" },
"time": { "example": "20260818", "type": "string" },
"volume": { "nullable": true, "type": "number" }
},
"required": ["time", "open", "high", "low", "close"],
"type": "object"
},
"FundDataDto": { "FundDataDto": {
"properties": { "properties": {
"changePercent": { "type": "number" }, "changePercent": { "type": "number" },
@@ -70,7 +173,7 @@
"type": "object" "type": "object"
}, },
"snapshotId": { "type": "string" }, "snapshotId": { "type": "string" },
"source": { "enum": ["tdx"], "type": "string" }, "source": { "enum": ["tdx", "aktools", "mixed"], "type": "string" },
"status": { "status": {
"enum": ["fresh", "delayed", "partial", "unavailable"], "enum": ["fresh", "delayed", "partial", "unavailable"],
"type": "string" "type": "string"
@@ -101,6 +204,40 @@
"required": ["status", "process", "checkedAt", "dependencies"], "required": ["status", "process", "checkedAt", "dependencies"],
"type": "object" "type": "object"
}, },
"IndexDetailDataDto": {
"properties": {
"availableViews": {
"$ref": "#/components/schemas/DetailAvailableViewsDto"
},
"instrument": { "$ref": "#/components/schemas/DetailInstrumentDto" },
"quote": { "$ref": "#/components/schemas/DetailQuoteDto" },
"series": {
"items": { "$ref": "#/components/schemas/DetailSeriesPointDto" },
"type": "array"
}
},
"required": ["instrument", "quote", "series", "availableViews"],
"type": "object"
},
"IndexDetailResponseDto": {
"properties": {
"asOf": { "format": "date-time", "type": "string" },
"data": {
"allOf": [{ "$ref": "#/components/schemas/IndexDetailDataDto" }],
"nullable": true,
"type": "object"
},
"snapshotId": { "type": "string" },
"source": { "enum": ["tdx", "aktools", "mixed"], "type": "string" },
"status": {
"enum": ["fresh", "delayed", "partial", "unavailable"],
"type": "string"
},
"warnings": { "items": { "type": "string" }, "type": "array" }
},
"required": ["snapshotId", "asOf", "source", "status", "warnings", "data"],
"type": "object"
},
"IndexQuoteDto": { "IndexQuoteDto": {
"properties": { "properties": {
"changePercent": { "nullable": true, "type": "number" }, "changePercent": { "nullable": true, "type": "number" },
@@ -132,7 +269,7 @@
"type": "object" "type": "object"
}, },
"snapshotId": { "type": "string" }, "snapshotId": { "type": "string" },
"source": { "enum": ["tdx"], "type": "string" }, "source": { "enum": ["tdx", "aktools", "mixed"], "type": "string" },
"status": { "status": {
"enum": ["fresh", "delayed", "partial", "unavailable"], "enum": ["fresh", "delayed", "partial", "unavailable"],
"type": "string" "type": "string"
@@ -152,6 +289,40 @@
"required": ["items"], "required": ["items"],
"type": "object" "type": "object"
}, },
"SectorDetailDataDto": {
"properties": {
"availableViews": {
"$ref": "#/components/schemas/DetailAvailableViewsDto"
},
"instrument": { "$ref": "#/components/schemas/DetailInstrumentDto" },
"quote": { "$ref": "#/components/schemas/DetailQuoteDto" },
"series": {
"items": { "$ref": "#/components/schemas/DetailSeriesPointDto" },
"type": "array"
}
},
"required": ["instrument", "quote", "series", "availableViews"],
"type": "object"
},
"SectorDetailResponseDto": {
"properties": {
"asOf": { "format": "date-time", "type": "string" },
"data": {
"allOf": [{ "$ref": "#/components/schemas/SectorDetailDataDto" }],
"nullable": true,
"type": "object"
},
"snapshotId": { "type": "string" },
"source": { "enum": ["tdx", "aktools", "mixed"], "type": "string" },
"status": {
"enum": ["fresh", "delayed", "partial", "unavailable"],
"type": "string"
},
"warnings": { "items": { "type": "string" }, "type": "array" }
},
"required": ["snapshotId", "asOf", "source", "status", "warnings", "data"],
"type": "object"
},
"SectorQuoteDto": { "SectorQuoteDto": {
"properties": { "properties": {
"changePercent": { "type": "number" }, "changePercent": { "type": "number" },
@@ -163,6 +334,66 @@
"required": ["code", "name", "changePercent", "direction", "tradeDate"], "required": ["code", "name", "changePercent", "direction", "tradeDate"],
"type": "object" "type": "object"
}, },
"SectorRankingItemDto": {
"properties": {
"advancingCount": { "nullable": true, "type": "number" },
"category": {
"enum": ["industry", "concept", "board"],
"type": "string"
},
"changePercent": { "nullable": true, "type": "number" },
"code": { "type": "string" },
"decliningCount": { "nullable": true, "type": "number" },
"leader": { "nullable": true, "type": "string" },
"name": { "type": "string" },
"tradeDate": {
"example": "20260825",
"nullable": true,
"type": "string"
},
"turnoverAmount": {
"description": "亿元",
"nullable": true,
"type": "number"
}
},
"required": ["category", "code", "name"],
"type": "object"
},
"SectorRankingsDataDto": {
"properties": {
"category": {
"enum": ["industry", "concept", "board"],
"type": "string"
},
"items": {
"items": { "$ref": "#/components/schemas/SectorRankingItemDto" },
"type": "array"
},
"sort": { "enum": ["rise", "fall", "turnover"], "type": "string" }
},
"required": ["items", "sort", "category"],
"type": "object"
},
"SectorRankingsResponseDto": {
"properties": {
"asOf": { "format": "date-time", "type": "string" },
"data": {
"allOf": [{ "$ref": "#/components/schemas/SectorRankingsDataDto" }],
"nullable": true,
"type": "object"
},
"snapshotId": { "type": "string" },
"source": { "enum": ["tdx", "aktools", "mixed"], "type": "string" },
"status": {
"enum": ["fresh", "delayed", "partial", "unavailable"],
"type": "string"
},
"warnings": { "items": { "type": "string" }, "type": "array" }
},
"required": ["snapshotId", "asOf", "source", "status", "warnings", "data"],
"type": "object"
},
"SectorsResponseDto": { "SectorsResponseDto": {
"properties": { "properties": {
"asOf": { "format": "date-time", "type": "string" }, "asOf": { "format": "date-time", "type": "string" },
@@ -172,7 +403,7 @@
"type": "object" "type": "object"
}, },
"snapshotId": { "type": "string" }, "snapshotId": { "type": "string" },
"source": { "enum": ["tdx"], "type": "string" }, "source": { "enum": ["tdx", "aktools", "mixed"], "type": "string" },
"status": { "status": {
"enum": ["fresh", "delayed", "partial", "unavailable"], "enum": ["fresh", "delayed", "partial", "unavailable"],
"type": "string" "type": "string"
@@ -305,7 +536,55 @@
"type": "object" "type": "object"
}, },
"snapshotId": { "type": "string" }, "snapshotId": { "type": "string" },
"source": { "enum": ["tdx"], "type": "string" }, "source": { "enum": ["tdx", "aktools", "mixed"], "type": "string" },
"status": {
"enum": ["fresh", "delayed", "partial", "unavailable"],
"type": "string"
},
"warnings": { "items": { "type": "string" }, "type": "array" }
},
"required": ["snapshotId", "asOf", "source", "status", "warnings", "data"],
"type": "object"
},
"TurnoverPointDto": {
"properties": {
"amount": {
"description": "该时间区间成交额,单位亿元",
"type": "number"
},
"cumulativeAmount": {
"description": "当日累计成交额,单位亿元",
"type": "number"
},
"time": { "format": "date-time", "type": "string" }
},
"required": ["time", "amount", "cumulativeAmount"],
"type": "object"
},
"TurnoverTrendDataDto": {
"properties": {
"granularity": { "enum": ["5", "15"], "type": "string" },
"invalidPointCount": { "type": "number" },
"marketCount": { "maximum": 3, "minimum": 1, "type": "number" },
"points": {
"items": { "$ref": "#/components/schemas/TurnoverPointDto" },
"type": "array"
},
"unit": { "example": "亿元", "type": "string" }
},
"required": ["points", "granularity", "unit", "marketCount", "invalidPointCount"],
"type": "object"
},
"TurnoverTrendResponseDto": {
"properties": {
"asOf": { "format": "date-time", "type": "string" },
"data": {
"allOf": [{ "$ref": "#/components/schemas/TurnoverTrendDataDto" }],
"nullable": true,
"type": "object"
},
"snapshotId": { "type": "string" },
"source": { "enum": ["tdx", "aktools", "mixed"], "type": "string" },
"status": { "status": {
"enum": ["fresh", "delayed", "partial", "unavailable"], "enum": ["fresh", "delayed", "partial", "unavailable"],
"type": "string" "type": "string"
@@ -367,6 +646,47 @@
"tags": ["market-overview"] "tags": ["market-overview"]
} }
}, },
"/api/v1/market-overview/capital-flow": {
"get": {
"operationId": "MarketStructureController_getCapitalFlow",
"parameters": [
{
"in": "query",
"name": "limit",
"required": false,
"schema": { "maximum": 100, "minimum": 1, "type": "number" }
},
{
"in": "query",
"name": "sort",
"required": false,
"schema": { "enum": ["inflow", "outflow"], "type": "string" }
},
{
"in": "query",
"name": "category",
"required": false,
"schema": {
"enum": ["industry", "concept", "board"],
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CapitalFlowResponseDto"
}
}
},
"description": ""
}
},
"tags": ["market-overview-structure"]
}
},
"/api/v1/market-overview/funds": { "/api/v1/market-overview/funds": {
"get": { "get": {
"operationId": "MarketOverviewController_getFunds", "operationId": "MarketOverviewController_getFunds",
@@ -401,6 +721,91 @@
"tags": ["market-overview"] "tags": ["market-overview"]
} }
}, },
"/api/v1/market-overview/indices/{code}/detail": {
"get": {
"operationId": "MarketDetailController_getIndexDetail",
"parameters": [
{
"in": "query",
"name": "count",
"required": false,
"schema": {
"example": 120,
"maximum": 120,
"minimum": 1,
"type": "number"
}
},
{
"in": "query",
"name": "period",
"required": false,
"schema": { "enum": ["1d"], "type": "string" }
},
{
"description": "带市场后缀的指数代码",
"in": "path",
"name": "code",
"required": true,
"schema": { "example": "000001.SH" }
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/IndexDetailResponseDto"
}
}
},
"description": ""
}
},
"tags": ["market-overview-detail"]
}
},
"/api/v1/market-overview/sector-rankings": {
"get": {
"operationId": "MarketStructureController_getSectorRankings",
"parameters": [
{
"in": "query",
"name": "limit",
"required": false,
"schema": { "maximum": 100, "minimum": 1, "type": "number" }
},
{
"in": "query",
"name": "sort",
"required": false,
"schema": { "enum": ["rise", "fall", "turnover"], "type": "string" }
},
{
"in": "query",
"name": "category",
"required": false,
"schema": {
"enum": ["industry", "concept", "board"],
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SectorRankingsResponseDto"
}
}
},
"description": ""
}
},
"tags": ["market-overview-structure"]
}
},
"/api/v1/market-overview/sectors": { "/api/v1/market-overview/sectors": {
"get": { "get": {
"operationId": "MarketOverviewController_getSectors", "operationId": "MarketOverviewController_getSectors",
@@ -418,6 +823,50 @@
"tags": ["market-overview"] "tags": ["market-overview"]
} }
}, },
"/api/v1/market-overview/sectors/{code}/detail": {
"get": {
"operationId": "MarketDetailController_getSectorDetail",
"parameters": [
{
"in": "query",
"name": "count",
"required": false,
"schema": {
"example": 120,
"maximum": 120,
"minimum": 1,
"type": "number"
}
},
{
"in": "query",
"name": "period",
"required": false,
"schema": { "enum": ["1d"], "type": "string" }
},
{
"description": "带市场后缀的一级行业代码",
"in": "path",
"name": "code",
"required": true,
"schema": { "example": "881001.SH" }
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SectorDetailResponseDto"
}
}
},
"description": ""
}
},
"tags": ["market-overview-detail"]
}
},
"/api/v1/market-overview/short-term/conclusion": { "/api/v1/market-overview/short-term/conclusion": {
"get": { "get": {
"operationId": "ShortTermMarketController_getConclusion", "operationId": "ShortTermMarketController_getConclusion",
@@ -472,6 +921,38 @@
}, },
"tags": ["market-overview"] "tags": ["market-overview"]
} }
},
"/api/v1/market-overview/turnover-trend": {
"get": {
"operationId": "MarketStructureController_getTurnoverTrend",
"parameters": [
{
"in": "query",
"name": "tradeDate",
"required": false,
"schema": { "pattern": "^\\d{8}$", "type": "string" }
},
{
"in": "query",
"name": "interval",
"required": false,
"schema": { "enum": ["5", "15"], "type": "string" }
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TurnoverTrendResponseDto"
}
}
},
"description": ""
}
},
"tags": ["market-overview-structure"]
}
} }
}, },
"servers": [], "servers": [],
+25 -1
View File
@@ -9,7 +9,14 @@ import { format } from 'prettier';
import { HealthController } from '../apps/api/src/health.controller'; import { HealthController } from '../apps/api/src/health.controller';
import { MarketOverviewController } from '../apps/api/src/market-overview/market-overview.controller'; import { MarketOverviewController } from '../apps/api/src/market-overview/market-overview.controller';
import { MarketDetailController } from '../apps/api/src/market-overview/detail/market-detail.controller';
import { ShortTermMarketController } from '../apps/api/src/market-overview/short-term.controller'; import { ShortTermMarketController } from '../apps/api/src/market-overview/short-term.controller';
import { MarketStructureController } from '../apps/api/src/market-overview/structure/market-structure.controller';
import { createAktoolsStructureClient } from '../apps/api/src/market-overview/structure/aktools-structure.client';
import {
MARKET_STRUCTURE_SOURCE_PORT,
MarketStructureService,
} from '../apps/api/src/market-overview/structure/market-structure.service';
import { import {
HealthService, HealthService,
MYSQL_HEALTH_PORT, MYSQL_HEALTH_PORT,
@@ -19,6 +26,7 @@ import {
MarketOverviewService, MarketOverviewService,
TDX_CLIENT_PORT, TDX_CLIENT_PORT,
} from '../apps/api/src/market-overview/market-overview.service'; } from '../apps/api/src/market-overview/market-overview.service';
import { MarketDetailService } from '../apps/api/src/market-overview/detail/market-detail.service';
import { createAktoolsClient } from '../apps/api/src/market-overview/aktools/aktools.client'; import { createAktoolsClient } from '../apps/api/src/market-overview/aktools/aktools.client';
import { import {
AKTOOLS_CLIENT_PORT, AKTOOLS_CLIENT_PORT,
@@ -27,12 +35,20 @@ import {
import { DisabledHealthClient } from '../libs/infrastructure/src/disabled-health.client'; import { DisabledHealthClient } from '../libs/infrastructure/src/disabled-health.client';
@Module({ @Module({
controllers: [HealthController, MarketOverviewController, ShortTermMarketController], controllers: [
HealthController,
MarketOverviewController,
MarketDetailController,
ShortTermMarketController,
MarketStructureController,
],
providers: [ providers: [
HealthService, HealthService,
MarketOverviewService, MarketOverviewService,
MarketDetailService,
{ provide: TDX_CLIENT_PORT, useValue: { request: async () => ({}) } }, { provide: TDX_CLIENT_PORT, useValue: { request: async () => ({}) } },
ShortTermMarketService, ShortTermMarketService,
MarketStructureService,
{ {
provide: AKTOOLS_CLIENT_PORT, provide: AKTOOLS_CLIENT_PORT,
useValue: createAktoolsClient({ useValue: createAktoolsClient({
@@ -41,6 +57,14 @@ import { DisabledHealthClient } from '../libs/infrastructure/src/disabled-health
timeoutMs: 10_000, timeoutMs: 10_000,
}), }),
}, },
{
provide: MARKET_STRUCTURE_SOURCE_PORT,
useValue: createAktoolsStructureClient({
enabled: false,
baseUrl: 'http://127.0.0.1:8080',
timeoutMs: 10_000,
}),
},
{ provide: REDIS_HEALTH_PORT, useValue: new DisabledHealthClient('文档生成不连接 Redis') }, { provide: REDIS_HEALTH_PORT, useValue: new DisabledHealthClient('文档生成不连接 Redis') },
{ provide: MYSQL_HEALTH_PORT, useValue: new DisabledHealthClient('文档生成不连接 MySQL') }, { provide: MYSQL_HEALTH_PORT, useValue: new DisabledHealthClient('文档生成不连接 MySQL') },
], ],
@@ -0,0 +1,283 @@
#!/usr/bin/env node
const candidates = Object.freeze([
'stock_board_industry_spot_em',
'stock_board_concept_spot_em',
'stock_sector_fund_flow_rank',
'stock_market_fund_flow',
'stock_zh_a_spot_em',
]);
const tdxSymbols = Object.freeze(['000001.SH', '399001.SZ', '899050.BJ']);
const tdxPeriods = Object.freeze(['1m', '5m', '15m', '1d']);
const DEFAULT_AKTOOLS_BASE_URL = 'http://127.0.0.1:8080';
const DEFAULT_TDX_BASE_URL = 'http://127.0.0.1:17710';
const TIMEOUT_MS = 10_000;
const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
function usage() {
return `市场结构能力探针(只读)\n\n用法:node tools/probes/market-structure-capability.mjs [--base-url http://127.0.0.1:8080]\n\n仅允许本机 AKTools(127.0.0.1:8080)及本机 TDX(127.0.0.1:17710)。输出不会包含原始行、凭据、请求体或异常详情。`;
}
function parseLocalBaseUrl(value, expectedPort, optionName) {
let parsed;
try {
parsed = new URL(value);
} catch {
throw new Error(`${optionName} 必须是有效 URL`);
}
if (
parsed.protocol !== 'http:' ||
parsed.hostname !== '127.0.0.1' ||
parsed.port !== expectedPort
) {
throw new Error(`${optionName} 必须指向本机 127.0.0.1:${expectedPort}`);
}
if (parsed.pathname !== '' && parsed.pathname !== '/') {
throw new Error(`${optionName} 不得包含路径`);
}
return parsed.toString().replace(/\/$/, '');
}
function parseArguments(argv) {
let baseUrl = DEFAULT_AKTOOLS_BASE_URL;
for (let index = 0; index < argv.length; index += 1) {
if (argv[index] === '--help' || argv[index] === '-h') return { help: true };
if (argv[index] === '--base-url' && argv[index + 1]) {
baseUrl = argv[index + 1];
index += 1;
continue;
}
throw new Error(`不支持的参数:${argv[index]}`);
}
return {
aktoolsBaseUrl: parseLocalBaseUrl(baseUrl, '8080', '--base-url'),
tdxBaseUrl: parseLocalBaseUrl(
process.env.TDX_BASE_URL || DEFAULT_TDX_BASE_URL,
'17710',
'TDX_BASE_URL',
),
};
}
function classifyError(error) {
if (error?.name === 'AbortError') return 'timeout';
if (typeof error?.message === 'string' && error.message.startsWith('http_')) return error.message;
if (error instanceof SyntaxError) return 'invalid_json';
return 'network';
}
function getRows(value) {
if (Array.isArray(value)) return value;
if (value && typeof value === 'object') {
for (const key of ['data', 'result', 'Value']) {
const candidate = value[key];
if (Array.isArray(candidate)) return candidate;
}
}
return [];
}
function getKeys(rows) {
return [
...new Set(rows.flatMap((row) => (row && typeof row === 'object' ? Object.keys(row) : []))),
].sort();
}
function getTimestamp(row) {
if (!row || typeof row !== 'object') return null;
for (const key of ['Date', 'date', 'datetime', 'time', '时间', '日期']) {
const value = row[key];
if (typeof value === 'string' || typeof value === 'number') return String(value);
}
return null;
}
function isIntradayTimestamp(timestamp) {
return typeof timestamp === 'string' && /(?:T|\s)\d{2}:\d{2}/.test(timestamp);
}
function isShanghaiTradingSession(now = new Date()) {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
weekday: 'short',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).formatToParts(now);
const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
if (values.weekday === 'Sat' || values.weekday === 'Sun') return false;
const minutes = Number(values.hour) * 60 + Number(values.minute);
return (
(minutes >= 9 * 60 + 30 && minutes <= 11 * 60 + 30) ||
(minutes >= 13 * 60 && minutes <= 15 * 60)
);
}
async function fetchJson(url, options) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const response = await fetch(url, { ...options, signal: controller.signal });
const contentLength = response.headers.get('content-length');
if (contentLength && Number(contentLength) > MAX_RESPONSE_BYTES)
throw new Error('http_response_too_large');
const text = await response.text();
if (Buffer.byteLength(text, 'utf8') > MAX_RESPONSE_BYTES)
throw new Error('http_response_too_large');
if (!response.ok) throw new Error(`http_${response.status}`);
return JSON.parse(text);
} finally {
clearTimeout(timeout);
}
}
function summarizeRows(rows) {
const firstTimestamp = getTimestamp(rows[0]);
const lastTimestamp = getTimestamp(rows.at(-1));
return {
rowCount: rows.length,
keys: getKeys(rows),
firstTimestamp,
lastTimestamp,
};
}
function summarizeTdxSeries(series) {
const dates = Array.isArray(series?.Date) ? series.Date : [];
return {
rowCount: dates.length,
keys: series && typeof series === 'object' ? Object.keys(series).sort() : [],
firstTimestamp: dates.length ? String(dates[0]) : null,
lastTimestamp: dates.length ? String(dates.at(-1)) : null,
};
}
async function probeAktools(baseUrl, endpoint) {
const startedAt = performance.now();
try {
const value = await fetchJson(`${baseUrl}/api/public/${endpoint}`, {
headers: { accept: 'application/json' },
});
const rows = getRows(value);
return {
source: 'aktools',
endpoint,
status: rows.length > 0 ? 'usable' : 'partial',
elapsedMs: Math.round(performance.now() - startedAt),
...summarizeRows(rows),
errorClass: null,
};
} catch (error) {
return {
source: 'aktools',
endpoint,
status: 'unavailable',
elapsedMs: Math.round(performance.now() - startedAt),
rowCount: 0,
keys: [],
firstTimestamp: null,
lastTimestamp: null,
errorClass: classifyError(error),
};
}
}
function analyzeTdxRows(series) {
const dates = Array.isArray(series?.Date) ? series.Date : [];
const amounts = Array.isArray(series?.Amount) ? series.Amount : [];
return {
dateAmountAligned: dates.length > 0 && dates.length === amounts.length,
timestampsIntraday:
dates.length > 0 && dates.every((date) => isIntradayTimestamp(String(date))),
};
}
async function probeTdx(baseUrl, symbol, period) {
const startedAt = performance.now();
try {
const envelope = await fetchJson(`${baseUrl}/`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
id: 1,
method: 'get_market_data',
params: { stock_list: [symbol], count: 30, dividend_type: 'none', period },
}),
});
const series = envelope?.result?.Value?.[symbol];
const businessError =
envelope?.error !== undefined ||
!envelope?.result ||
(envelope.result.ErrorId !== undefined &&
envelope.result.ErrorId !== 0 &&
envelope.result.ErrorId !== '0');
const summary = summarizeTdxSeries(series);
return {
source: 'tdx',
symbol,
period,
status: businessError ? 'unavailable' : summary.rowCount > 0 ? 'usable' : 'partial',
elapsedMs: Math.round(performance.now() - startedAt),
...summary,
...analyzeTdxRows(series),
errorClass: businessError ? 'protocol' : null,
};
} catch (error) {
return {
source: 'tdx',
symbol,
period,
status: 'unavailable',
elapsedMs: Math.round(performance.now() - startedAt),
rowCount: 0,
keys: [],
firstTimestamp: null,
lastTimestamp: null,
dateAmountAligned: false,
timestampsIntraday: false,
errorClass: classifyError(error),
};
}
}
async function main() {
const options = parseArguments(process.argv.slice(2));
if (options.help) {
console.log(usage());
return;
}
const aktools = await Promise.all(
candidates.map((endpoint) => probeAktools(options.aktoolsBaseUrl, endpoint)),
);
const tdx = await Promise.all(
tdxSymbols.flatMap((symbol) =>
tdxPeriods.map((period) => probeTdx(options.tdxBaseUrl, symbol, period)),
),
);
console.log(
JSON.stringify(
{
observedAt: new Date().toISOString(),
aktoolsBaseUrl: options.aktoolsBaseUrl,
tdxBaseUrl: options.tdxBaseUrl,
tradingSession: isShanghaiTradingSession(),
freshnessNote: isShanghaiTradingSession()
? '盘中观测:最后时间可用于后续新鲜度判断。'
: '非盘中观测:最后一条记录仅表示收盘后最后记录,不能证明盘中新鲜度。',
aktools,
tdx,
},
null,
2,
),
);
}
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((error) => {
console.error(JSON.stringify({ errorClass: 'configuration', message: error.message }));
process.exitCode = 1;
});
}
export { analyzeTdxRows, isShanghaiTradingSession, parseArguments, summarizeRows };
@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { spawnSync } from 'node:child_process';
import { analyzeTdxRows, parseArguments } from './market-structure-capability.mjs';
test('帮助信息声明固定的本机数据源边界', () => {
const result = spawnSync(
process.execPath,
['tools/probes/market-structure-capability.mjs', '--help'],
{
cwd: new URL('../..', import.meta.url),
encoding: 'utf8',
},
);
assert.equal(result.status, 0);
assert.match(result.stdout, /127\.0\.0\.1:8080/);
assert.match(result.stdout, /只读/);
});
test('TDX 列式序列在 Date 与 Amount 等长且分钟时间戳完整时标记为对齐', () => {
assert.deepEqual(
analyzeTdxRows({
Date: ['2026-08-25 09:30:00', '2026-08-25 09:35:00'],
Amount: ['10', '12'],
}),
{ dateAmountAligned: true, timestampsIntraday: true },
);
});
test('探针拒绝非回环地址和错误端口', () => {
assert.throws(
() => parseArguments(['--base-url', 'http://192.168.1.10:8080']),
/127\.0\.0\.1:8080/,
);
assert.throws(() => parseArguments(['--base-url', 'http://127.0.0.1:9000']), /127\.0\.0\.1:8080/);
});
+357 -5
View File
@@ -36,6 +36,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
'/api/v1/market-overview/capital-flow': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations['MarketStructureController_getCapitalFlow'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/market-overview/funds': { '/api/v1/market-overview/funds': {
parameters: { parameters: {
query?: never; query?: never;
@@ -68,6 +84,38 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
'/api/v1/market-overview/indices/{code}/detail': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations['MarketDetailController_getIndexDetail'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/market-overview/sector-rankings': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations['MarketStructureController_getSectorRankings'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/market-overview/sectors': { '/api/v1/market-overview/sectors': {
parameters: { parameters: {
query?: never; query?: never;
@@ -84,6 +132,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
'/api/v1/market-overview/sectors/{code}/detail': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations['MarketDetailController_getSectorDetail'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/market-overview/short-term/conclusion': { '/api/v1/market-overview/short-term/conclusion': {
parameters: { parameters: {
query?: never; query?: never;
@@ -132,6 +196,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
'/api/v1/market-overview/turnover-trend': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations['MarketStructureController_getTurnoverTrend'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
} }
export type webhooks = Record<string, never>; export type webhooks = Record<string, never>;
export interface components { export interface components {
@@ -147,7 +227,39 @@ export interface components {
data: components['schemas']['BreadthDataDto'] | null; data: components['schemas']['BreadthDataDto'] | null;
snapshotId: string; snapshotId: string;
/** @enum {string} */ /** @enum {string} */
source: 'tdx'; source: 'tdx' | 'aktools' | 'mixed';
/** @enum {string} */
status: 'fresh' | 'delayed' | 'partial' | 'unavailable';
warnings: string[];
};
CapitalFlowDataDto: {
/** @enum {string} */
category: 'industry' | 'concept' | 'board';
items: components['schemas']['CapitalFlowItemDto'][];
/** @enum {string} */
sort: 'inflow' | 'outflow';
};
CapitalFlowItemDto: {
/** @enum {string} */
category: 'industry' | 'concept' | 'board';
code?: string | null;
flowDefinition: string;
name: string;
/** @description 亿元 */
netInflow?: number | null;
/** @description 亿元 */
netOutflow?: number | null;
tradeDate?: string | null;
/** @description 亿元 */
turnoverAmount?: number | null;
};
CapitalFlowResponseDto: {
/** Format: date-time */
asOf: string;
data: components['schemas']['CapitalFlowDataDto'] | null;
snapshotId: string;
/** @enum {string} */
source: 'tdx' | 'aktools' | 'mixed';
/** @enum {string} */ /** @enum {string} */
status: 'fresh' | 'delayed' | 'partial' | 'unavailable'; status: 'fresh' | 'delayed' | 'partial' | 'unavailable';
warnings: string[]; warnings: string[];
@@ -157,6 +269,34 @@ export interface components {
/** @enum {string} */ /** @enum {string} */
status: 'healthy' | 'unavailable' | 'disabled'; status: 'healthy' | 'unavailable' | 'disabled';
}; };
DetailAvailableViewsDto: {
intraday: boolean;
kline: boolean;
};
DetailInstrumentDto: {
code: string;
name: string;
/** @enum {string} */
type: 'index' | 'sector';
};
DetailQuoteDto: {
changePercent?: number | null;
/** @enum {string} */
direction: 'up' | 'down' | 'flat';
price: number;
/** @example 20260818 */
tradeDate: string;
};
DetailSeriesPointDto: {
amount?: number | null;
close: number;
high: number;
low: number;
open: number;
/** @example 20260818 */
time: string;
volume?: number | null;
};
FundDataDto: { FundDataDto: {
changePercent: number; changePercent: number;
currentAmount: number; currentAmount: number;
@@ -175,7 +315,7 @@ export interface components {
data: components['schemas']['FundDataDto'] | null; data: components['schemas']['FundDataDto'] | null;
snapshotId: string; snapshotId: string;
/** @enum {string} */ /** @enum {string} */
source: 'tdx'; source: 'tdx' | 'aktools' | 'mixed';
/** @enum {string} */ /** @enum {string} */
status: 'fresh' | 'delayed' | 'partial' | 'unavailable'; status: 'fresh' | 'delayed' | 'partial' | 'unavailable';
warnings: string[]; warnings: string[];
@@ -194,6 +334,23 @@ export interface components {
/** @enum {string} */ /** @enum {string} */
status: 'healthy' | 'unavailable'; status: 'healthy' | 'unavailable';
}; };
IndexDetailDataDto: {
availableViews: components['schemas']['DetailAvailableViewsDto'];
instrument: components['schemas']['DetailInstrumentDto'];
quote: components['schemas']['DetailQuoteDto'];
series: components['schemas']['DetailSeriesPointDto'][];
};
IndexDetailResponseDto: {
/** Format: date-time */
asOf: string;
data: components['schemas']['IndexDetailDataDto'] | null;
snapshotId: string;
/** @enum {string} */
source: 'tdx' | 'aktools' | 'mixed';
/** @enum {string} */
status: 'fresh' | 'delayed' | 'partial' | 'unavailable';
warnings: string[];
};
IndexQuoteDto: { IndexQuoteDto: {
changePercent?: number | null; changePercent?: number | null;
code: string; code: string;
@@ -212,7 +369,7 @@ export interface components {
data: components['schemas']['IndicesDataDto'] | null; data: components['schemas']['IndicesDataDto'] | null;
snapshotId: string; snapshotId: string;
/** @enum {string} */ /** @enum {string} */
source: 'tdx'; source: 'tdx' | 'aktools' | 'mixed';
/** @enum {string} */ /** @enum {string} */
status: 'fresh' | 'delayed' | 'partial' | 'unavailable'; status: 'fresh' | 'delayed' | 'partial' | 'unavailable';
warnings: string[]; warnings: string[];
@@ -220,6 +377,23 @@ export interface components {
SectorDataDto: { SectorDataDto: {
items: components['schemas']['SectorQuoteDto'][]; items: components['schemas']['SectorQuoteDto'][];
}; };
SectorDetailDataDto: {
availableViews: components['schemas']['DetailAvailableViewsDto'];
instrument: components['schemas']['DetailInstrumentDto'];
quote: components['schemas']['DetailQuoteDto'];
series: components['schemas']['DetailSeriesPointDto'][];
};
SectorDetailResponseDto: {
/** Format: date-time */
asOf: string;
data: components['schemas']['SectorDetailDataDto'] | null;
snapshotId: string;
/** @enum {string} */
source: 'tdx' | 'aktools' | 'mixed';
/** @enum {string} */
status: 'fresh' | 'delayed' | 'partial' | 'unavailable';
warnings: string[];
};
SectorQuoteDto: { SectorQuoteDto: {
changePercent: number; changePercent: number;
code: string; code: string;
@@ -228,13 +402,45 @@ export interface components {
name: string; name: string;
tradeDate: string; tradeDate: string;
}; };
SectorRankingItemDto: {
advancingCount?: number | null;
/** @enum {string} */
category: 'industry' | 'concept' | 'board';
changePercent?: number | null;
code: string;
decliningCount?: number | null;
leader?: string | null;
name: string;
/** @example 20260825 */
tradeDate?: string | null;
/** @description 亿元 */
turnoverAmount?: number | null;
};
SectorRankingsDataDto: {
/** @enum {string} */
category: 'industry' | 'concept' | 'board';
items: components['schemas']['SectorRankingItemDto'][];
/** @enum {string} */
sort: 'rise' | 'fall' | 'turnover';
};
SectorRankingsResponseDto: {
/** Format: date-time */
asOf: string;
data: components['schemas']['SectorRankingsDataDto'] | null;
snapshotId: string;
/** @enum {string} */
source: 'tdx' | 'aktools' | 'mixed';
/** @enum {string} */
status: 'fresh' | 'delayed' | 'partial' | 'unavailable';
warnings: string[];
};
SectorsResponseDto: { SectorsResponseDto: {
/** Format: date-time */ /** Format: date-time */
asOf: string; asOf: string;
data: components['schemas']['SectorDataDto'] | null; data: components['schemas']['SectorDataDto'] | null;
snapshotId: string; snapshotId: string;
/** @enum {string} */ /** @enum {string} */
source: 'tdx'; source: 'tdx' | 'aktools' | 'mixed';
/** @enum {string} */ /** @enum {string} */
status: 'fresh' | 'delayed' | 'partial' | 'unavailable'; status: 'fresh' | 'delayed' | 'partial' | 'unavailable';
warnings: string[]; warnings: string[];
@@ -307,7 +513,35 @@ export interface components {
data: components['schemas']['SummaryDataDto'] | null; data: components['schemas']['SummaryDataDto'] | null;
snapshotId: string; snapshotId: string;
/** @enum {string} */ /** @enum {string} */
source: 'tdx'; source: 'tdx' | 'aktools' | 'mixed';
/** @enum {string} */
status: 'fresh' | 'delayed' | 'partial' | 'unavailable';
warnings: string[];
};
TurnoverPointDto: {
/** @description 该时间区间成交额,单位亿元 */
amount: number;
/** @description 当日累计成交额,单位亿元 */
cumulativeAmount: number;
/** Format: date-time */
time: string;
};
TurnoverTrendDataDto: {
/** @enum {string} */
granularity: '5' | '15';
invalidPointCount: number;
marketCount: number;
points: components['schemas']['TurnoverPointDto'][];
/** @example 亿元 */
unit: string;
};
TurnoverTrendResponseDto: {
/** Format: date-time */
asOf: string;
data: components['schemas']['TurnoverTrendDataDto'] | null;
snapshotId: string;
/** @enum {string} */
source: 'tdx' | 'aktools' | 'mixed';
/** @enum {string} */ /** @enum {string} */
status: 'fresh' | 'delayed' | 'partial' | 'unavailable'; status: 'fresh' | 'delayed' | 'partial' | 'unavailable';
warnings: string[]; warnings: string[];
@@ -367,6 +601,29 @@ export interface operations {
}; };
}; };
}; };
MarketStructureController_getCapitalFlow: {
parameters: {
query?: {
limit?: number;
sort?: 'inflow' | 'outflow';
category?: 'industry' | 'concept' | 'board';
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['CapitalFlowResponseDto'];
};
};
};
};
MarketOverviewController_getFunds: { MarketOverviewController_getFunds: {
parameters: { parameters: {
query?: never; query?: never;
@@ -405,6 +662,54 @@ export interface operations {
}; };
}; };
}; };
MarketDetailController_getIndexDetail: {
parameters: {
query?: {
count?: number;
period?: '1d';
};
header?: never;
path: {
/** @description 带市场后缀的指数代码 */
code: unknown;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['IndexDetailResponseDto'];
};
};
};
};
MarketStructureController_getSectorRankings: {
parameters: {
query?: {
limit?: number;
sort?: 'rise' | 'fall' | 'turnover';
category?: 'industry' | 'concept' | 'board';
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['SectorRankingsResponseDto'];
};
};
};
};
MarketOverviewController_getSectors: { MarketOverviewController_getSectors: {
parameters: { parameters: {
query?: never; query?: never;
@@ -424,6 +729,31 @@ export interface operations {
}; };
}; };
}; };
MarketDetailController_getSectorDetail: {
parameters: {
query?: {
count?: number;
period?: '1d';
};
header?: never;
path: {
/** @description 带市场后缀的一级行业代码 */
code: unknown;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['SectorDetailResponseDto'];
};
};
};
};
ShortTermMarketController_getConclusion: { ShortTermMarketController_getConclusion: {
parameters: { parameters: {
query?: never; query?: never;
@@ -481,4 +811,26 @@ export interface operations {
}; };
}; };
}; };
MarketStructureController_getTurnoverTrend: {
parameters: {
query?: {
tradeDate?: string;
interval?: '5' | '15';
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['TurnoverTrendResponseDto'];
};
};
};
};
} }
@@ -0,0 +1,30 @@
import { describe, expect, it, vi } from 'vitest';
import type { RequestClient } from '@/utils/request';
import { createMarketDetailApi } from './detail';
describe('市场详情业务 API', () => {
it('使用独立详情路径和固定查询参数请求指数与板块', async () => {
const requestMock = vi.fn((config: Parameters<RequestClient>[0]) => {
void config;
return Promise.resolve({});
});
const api = createMarketDetailApi(requestMock as unknown as RequestClient);
await api.getIndexDetail('000001.SH', { period: '1d', count: 120 });
await api.getSectorDetail('881001.SH', { period: '1d', count: 60 });
expect(requestMock.mock.calls.map(([config]) => config)).toEqual([
{
method: 'get',
url: '/api/v1/market-overview/indices/000001.SH/detail',
params: { period: '1d', count: 120 },
},
{
method: 'get',
url: '/api/v1/market-overview/sectors/881001.SH/detail',
params: { period: '1d', count: 60 },
},
]);
});
});
+28
View File
@@ -0,0 +1,28 @@
import request, { type RequestClient } from '@/utils/request';
import type { components } from '@/api/generated/schema';
export type IndexDetailResponse = components['schemas']['IndexDetailResponseDto'];
export type SectorDetailResponse = components['schemas']['SectorDetailResponseDto'];
export type DetailQuery = { period: '1d'; count: number };
/** 详情请求只在 API 文件中拼接受控路径,页面和组件不感知 Axios 或服务端地址。 */
export function createMarketDetailApi(requestClient: RequestClient = request) {
return {
getIndexDetail: (code: string, params: DetailQuery) =>
requestClient<IndexDetailResponse>({
method: 'get',
url: `/api/v1/market-overview/indices/${encodeURIComponent(code)}/detail`,
params,
}),
getSectorDetail: (code: string, params: DetailQuery) =>
requestClient<SectorDetailResponse>({
method: 'get',
url: `/api/v1/market-overview/sectors/${encodeURIComponent(code)}/detail`,
params,
}),
};
}
const marketDetailApi = createMarketDetailApi();
export default marketDetailApi;
@@ -17,6 +17,9 @@ describe('市场概览业务 API', () => {
await api.getBreadth(); await api.getBreadth();
await api.getSectors(); await api.getSectors();
await api.getFunds(); await api.getFunds();
await api.getSectorRankings({ category: 'industry', sort: 'rise', limit: 10 });
await api.getCapitalFlow({ category: 'industry', sort: 'inflow', limit: 10 });
await api.getTurnoverTrend({ interval: '5' });
await api.getShortTermEmotion(); await api.getShortTermEmotion();
await api.getShortTermConclusion(); await api.getShortTermConclusion();
@@ -26,6 +29,9 @@ describe('市场概览业务 API', () => {
'/api/v1/market-overview/breadth', '/api/v1/market-overview/breadth',
'/api/v1/market-overview/sectors', '/api/v1/market-overview/sectors',
'/api/v1/market-overview/funds', '/api/v1/market-overview/funds',
'/api/v1/market-overview/sector-rankings',
'/api/v1/market-overview/capital-flow',
'/api/v1/market-overview/turnover-trend',
'/api/v1/market-overview/short-term/emotion', '/api/v1/market-overview/short-term/emotion',
'/api/v1/market-overview/short-term/conclusion', '/api/v1/market-overview/short-term/conclusion',
]); ]);
+2
View File
@@ -8,9 +8,11 @@ import type {
ShortTermEmotionResponse, ShortTermEmotionResponse,
SummaryResponse, SummaryResponse,
} from '@/types/market-overview'; } from '@/types/market-overview';
import { createMarketStructureApi } from './structure';
export function createMarketOverviewApi(requestClient: RequestClient = request) { export function createMarketOverviewApi(requestClient: RequestClient = request) {
return { return {
...createMarketStructureApi(requestClient),
getSummary: () => getSummary: () =>
requestClient<SummaryResponse>({ method: 'get', url: '/api/v1/market-overview/summary' }), requestClient<SummaryResponse>({ method: 'get', url: '/api/v1/market-overview/summary' }),
getIndices: () => getIndices: () =>
@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from 'vitest';
import type { RequestClient } from '@/utils/request';
import { createMarketStructureApi } from './structure';
describe('市场结构业务 API', () => {
it('使用独立版本化路径和白名单查询参数', async () => {
const requestMock = vi.fn((config: Parameters<RequestClient>[0]) => {
void config;
return Promise.resolve({});
});
const api = createMarketStructureApi(requestMock as unknown as RequestClient);
await api.getSectorRankings({ category: 'industry', sort: 'rise', limit: 10 });
await api.getCapitalFlow({ category: 'concept', sort: 'outflow', limit: 20 });
await api.getTurnoverTrend({ interval: '5', tradeDate: '20260825' });
expect(requestMock.mock.calls).toEqual([
[
expect.objectContaining({
method: 'get',
url: '/api/v1/market-overview/sector-rankings',
params: { category: 'industry', sort: 'rise', limit: 10 },
}),
],
[
expect.objectContaining({
method: 'get',
url: '/api/v1/market-overview/capital-flow',
params: { category: 'concept', sort: 'outflow', limit: 20 },
}),
],
[
expect.objectContaining({
method: 'get',
url: '/api/v1/market-overview/turnover-trend',
params: { interval: '5', tradeDate: '20260825' },
}),
],
]);
});
});
+51
View File
@@ -0,0 +1,51 @@
import request, { type RequestClient } from '@/utils/request';
import type { components } from '@/api/generated/schema';
export type SectorRankingsResponse = components['schemas']['SectorRankingsResponseDto'];
export type CapitalFlowResponse = components['schemas']['CapitalFlowResponseDto'];
export type TurnoverTrendResponse = components['schemas']['TurnoverTrendResponseDto'];
export interface SectorRankingsQuery {
category: 'industry' | 'concept' | 'board';
sort: 'rise' | 'fall' | 'turnover';
limit: number;
}
export interface CapitalFlowQuery {
category: 'industry' | 'concept' | 'board';
sort: 'inflow' | 'outflow';
limit: number;
}
export interface TurnoverTrendQuery {
interval: '5' | '15';
tradeDate?: string;
}
/** 市场结构请求只调用固定的 Nest 路径,页面不感知 AKTools/TDX 地址。 */
export function createMarketStructureApi(requestClient: RequestClient = request) {
return {
getSectorRankings: (params: SectorRankingsQuery) =>
requestClient<SectorRankingsResponse>({
method: 'get',
url: '/api/v1/market-overview/sector-rankings',
params,
}),
getCapitalFlow: (params: CapitalFlowQuery) =>
requestClient<CapitalFlowResponse>({
method: 'get',
url: '/api/v1/market-overview/capital-flow',
params,
}),
getTurnoverTrend: (params: TurnoverTrendQuery) =>
requestClient<TurnoverTrendResponse>({
method: 'get',
url: '/api/v1/market-overview/turnover-trend',
params,
}),
};
}
const marketStructureApi = createMarketStructureApi();
export default marketStructureApi;
+13
View File
@@ -6,6 +6,7 @@ import {
} from 'vue-router'; } from 'vue-router';
import MarketOverviewPage from '@/views/market-overview/MarketOverviewPage.vue'; import MarketOverviewPage from '@/views/market-overview/MarketOverviewPage.vue';
import MarketDetailPage from '@/views/market-overview/detail/MarketDetailPage.vue';
const routes: RouteRecordRaw[] = [ const routes: RouteRecordRaw[] = [
{ {
@@ -13,6 +14,18 @@ const routes: RouteRecordRaw[] = [
name: 'home', name: 'home',
component: MarketOverviewPage, component: MarketOverviewPage,
}, },
{
path: '/market-overview/indices/:code',
name: 'market-index-detail',
component: MarketDetailPage,
meta: { detailType: 'index' },
},
{
path: '/market-overview/sectors/:code',
name: 'market-sector-detail',
component: MarketDetailPage,
meta: { detailType: 'sector' },
},
]; ];
/** 创建可注入 history 的路由实例,使浏览器运行与单元测试共享同一路由表。 */ /** 创建可注入 history 的路由实例,使浏览器运行与单元测试共享同一路由表。 */
+381 -1
View File
@@ -174,6 +174,226 @@ textarea {
opacity: 0.55; opacity: 0.55;
} }
.market-detail-page {
display: grid;
gap: 16px;
}
.detail-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 34px;
}
.quiet-button {
min-height: 32px;
padding: 0 10px;
color: #3f5667;
background: #fff;
border: 1px solid #ccd6de;
cursor: pointer;
font: inherit;
font-size: 12px;
}
.detail-toolbar-context,
.detail-chart-note {
color: #73808c;
font-size: 12px;
}
.detail-hero-panel,
.detail-quote-panel,
.detail-chart-panel,
.detail-facts-panel,
.detail-state-panel {
background: #fff;
border: 1px solid #dbe1e6;
border-radius: 7px;
box-shadow: 0 3px 14px rgb(26 38 49 / 5%);
}
.detail-hero-panel {
display: flex;
justify-content: space-between;
gap: 20px;
padding: 24px;
}
.detail-hero-panel h1 {
margin: 8px 0 4px;
color: #17212b;
font-size: clamp(24px, 4vw, 38px);
line-height: 1.15;
}
.detail-hero-panel p {
margin: 0;
color: #73808c;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px;
}
.detail-meta {
display: grid;
align-content: start;
justify-items: end;
gap: 7px;
min-width: 220px;
color: #64717d;
font-size: 12px;
text-align: right;
}
.detail-meta strong,
.detail-warning strong {
color: #177b70;
font-size: 12px;
}
.detail-meta strong[data-status='delayed'],
.detail-meta strong[data-status='partial'],
.detail-meta strong[data-status='unavailable'],
.detail-warning strong {
color: #a45e1b;
}
.detail-quote-panel {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1px;
overflow: hidden;
}
.detail-quote-panel > div {
display: grid;
gap: 8px;
min-height: 108px;
padding: 18px 20px;
background: #fff;
}
.detail-quote-panel span {
color: #71808c;
font-size: 12px;
}
.detail-quote-panel strong {
color: #1c2d3a;
font-size: 26px;
line-height: 1;
}
.detail-quote-panel strong[data-direction='up'] {
color: #c2483f;
}
.detail-quote-panel strong[data-direction='down'] {
color: #16806b;
}
.detail-section-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.detail-section-heading h2 {
margin: 5px 0 0;
color: #17212b;
font-size: 17px;
}
.detail-chart-panel,
.detail-facts-panel {
padding: 20px 22px;
}
.detail-chart {
display: block;
width: 100%;
height: auto;
min-height: 180px;
margin-top: 16px;
background: #fbfcfd;
border: 1px solid #edf0f3;
}
.detail-chart-axis {
stroke: #d9e0e5;
stroke-width: 1;
}
.detail-chart-line {
fill: none;
stroke: #167d82;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 3;
}
.detail-chart-empty {
display: grid;
min-height: 180px;
margin-top: 16px;
place-items: center;
color: #73808c;
background: #fbfcfd;
border: 1px dashed #cfd8df;
font-size: 13px;
}
.detail-chart-boundary {
margin: 10px 0 0;
color: #8b6d48;
font-size: 12px;
}
.detail-warning,
.detail-state-panel {
display: flex;
align-items: center;
gap: 10px;
min-height: 48px;
padding: 12px 16px;
color: #6e5b40;
background: #fff8ed;
border: 1px solid #f0d7b5;
border-radius: 7px;
font-size: 12px;
}
.detail-state-panel {
min-height: 150px;
justify-content: center;
flex-direction: column;
color: #667581;
background: #fff;
border-color: #dbe1e6;
text-align: center;
}
.detail-state-panel strong {
color: #273744;
font-size: 16px;
}
.detail-state-warning {
color: #7c613e;
background: #fff8ed;
border-color: #f0d7b5;
}
.detail-facts-panel p {
max-width: 720px;
margin: 14px 0 0;
color: #53626d;
font-size: 13px;
line-height: 1.7;
}
.overview-conclusion, .overview-conclusion,
.overview-panel { .overview-panel {
background: #fff; background: #fff;
@@ -322,6 +542,10 @@ textarea {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
} }
.overview-grid-structure {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.overview-panel { .overview-panel {
min-height: 190px; min-height: 190px;
padding: 18px; padding: 18px;
@@ -336,6 +560,125 @@ textarea {
white-space: nowrap; white-space: nowrap;
} }
.structure-panel {
min-width: 0;
}
.structure-tabs,
.structure-sort {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-bottom: 10px;
}
.structure-tabs button,
.structure-sort button {
min-height: 28px;
padding: 4px 9px;
color: #62717d;
background: #f2f5f6;
border: 1px solid #dfe6e9;
border-radius: 4px;
cursor: pointer;
font: inherit;
font-size: 11px;
}
.structure-tabs button.active,
.structure-sort button.active {
color: #11686c;
background: #e8f5f3;
border-color: #a9d3cf;
font-weight: 700;
}
.structure-list {
display: grid;
gap: 5px;
}
.structure-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 8px;
width: 100%;
min-height: 31px;
padding: 5px 7px;
color: #3e4d58;
background: #f7f9fa;
border: 1px solid transparent;
border-radius: 3px;
cursor: pointer;
font: inherit;
text-align: left;
}
.structure-row:hover {
border-color: #b9d7d4;
background: #f0f7f6;
}
.structure-row-static {
cursor: default;
}
.structure-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.structure-value {
color: #c33f3f;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.structure-value[data-direction='down'] {
color: #19846e;
}
.structure-row small {
max-width: 84px;
overflow: hidden;
color: #87939c;
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.turnover-summary {
display: flex;
align-items: baseline;
gap: 7px;
margin-top: 12px;
}
.turnover-summary strong {
color: #1c2a35;
font-size: 25px;
}
.turnover-summary span {
color: #7d8893;
font-size: 12px;
}
.turnover-chart {
display: block;
width: 100%;
height: 150px;
margin-top: 13px;
overflow: visible;
background: #f7f9fa;
border: 1px solid #e3e9ec;
border-radius: 4px;
}
.quiet-button { .quiet-button {
padding: 6px 9px; padding: 6px 9px;
color: #167d82; color: #167d82;
@@ -618,7 +961,8 @@ textarea {
@media (max-width: 900px) { @media (max-width: 900px) {
.overview-grid-top, .overview-grid-top,
.overview-grid-bottom { .overview-grid-bottom,
.overview-grid-structure {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
@@ -676,6 +1020,18 @@ textarea {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.structure-row {
grid-template-columns: minmax(0, 1fr) auto;
}
.structure-row small {
display: none;
}
.turnover-chart {
height: 130px;
}
.driver-column + .driver-column { .driver-column + .driver-column {
padding-top: 14px; padding-top: 14px;
padding-left: 0; padding-left: 0;
@@ -686,4 +1042,28 @@ textarea {
.index-grid { .index-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.detail-hero-panel {
flex-direction: column;
padding: 20px;
}
.detail-meta {
justify-items: start;
min-width: 0;
text-align: left;
}
.detail-quote-panel {
grid-template-columns: 1fr;
}
.detail-quote-panel > div {
min-height: 82px;
}
.detail-chart-panel,
.detail-facts-panel {
padding: 18px 16px;
}
} }
+6
View File
@@ -7,6 +7,9 @@ export type SectorsResponse = components['schemas']['SectorsResponseDto'];
export type FundsResponse = components['schemas']['FundsResponseDto']; export type FundsResponse = components['schemas']['FundsResponseDto'];
export type ShortTermEmotionResponse = components['schemas']['ShortTermEmotionResponseDto']; export type ShortTermEmotionResponse = components['schemas']['ShortTermEmotionResponseDto'];
export type ShortTermConclusionResponse = components['schemas']['ShortTermConclusionResponseDto']; export type ShortTermConclusionResponse = components['schemas']['ShortTermConclusionResponseDto'];
export type SectorRankingsResponse = components['schemas']['SectorRankingsResponseDto'];
export type CapitalFlowResponse = components['schemas']['CapitalFlowResponseDto'];
export type TurnoverTrendResponse = components['schemas']['TurnoverTrendResponseDto'];
export type SummaryData = NonNullable<SummaryResponse['data']>; export type SummaryData = NonNullable<SummaryResponse['data']>;
export type IndicesData = NonNullable<IndicesResponse['data']>; export type IndicesData = NonNullable<IndicesResponse['data']>;
export type BreadthData = NonNullable<BreadthResponse['data']>; export type BreadthData = NonNullable<BreadthResponse['data']>;
@@ -14,5 +17,8 @@ export type SectorsData = NonNullable<SectorsResponse['data']>;
export type FundsData = NonNullable<FundsResponse['data']>; export type FundsData = NonNullable<FundsResponse['data']>;
export type ShortTermEmotionData = NonNullable<ShortTermEmotionResponse['data']>; export type ShortTermEmotionData = NonNullable<ShortTermEmotionResponse['data']>;
export type ShortTermConclusionData = NonNullable<ShortTermConclusionResponse['data']>; export type ShortTermConclusionData = NonNullable<ShortTermConclusionResponse['data']>;
export type SectorRankingsData = NonNullable<SectorRankingsResponse['data']>;
export type CapitalFlowData = NonNullable<CapitalFlowResponse['data']>;
export type TurnoverTrendData = NonNullable<TurnoverTrendResponse['data']>;
export type OverviewModuleStatus = 'idle' | 'loading' | 'ready' | 'error'; export type OverviewModuleStatus = 'idle' | 'loading' | 'ready' | 'error';
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from 'vue'; import { onMounted } from 'vue';
import { useRouter } from 'vue-router';
import MarketBreadth from './components/MarketBreadth.vue'; import MarketBreadth from './components/MarketBreadth.vue';
import MarketConclusion from './components/MarketConclusion.vue'; import MarketConclusion from './components/MarketConclusion.vue';
@@ -8,6 +9,9 @@ import SectorOverview from './components/SectorOverview.vue';
import FundTrend from './components/FundTrend.vue'; import FundTrend from './components/FundTrend.vue';
import IndexOverview from './components/IndexOverview.vue'; import IndexOverview from './components/IndexOverview.vue';
import ShortTermEmotion from './components/ShortTermEmotion.vue'; import ShortTermEmotion from './components/ShortTermEmotion.vue';
import SectorRankingPanel from './components/SectorRankingPanel.vue';
import CapitalFlowPanel from './components/CapitalFlowPanel.vue';
import TurnoverTrendPanel from './components/TurnoverTrendPanel.vue';
import { useMarketOverview } from './useMarketOverview'; import { useMarketOverview } from './useMarketOverview';
const { const {
@@ -16,23 +20,34 @@ const {
breadth, breadth,
sectors, sectors,
funds, funds,
sectorRankings,
capitalFlow,
turnoverTrend,
shortTermEmotion, shortTermEmotion,
shortTermConclusion, shortTermConclusion,
sectorCategory,
capitalFlowCategory,
isLoading, isLoading,
analysisHorizon, analysisHorizon,
analysisEngine, analysisEngine,
refresh, refresh,
refreshSectorRankings,
refreshCapitalFlow,
setAnalysisHorizon, setAnalysisHorizon,
setAnalysisEngine, setAnalysisEngine,
} = useMarketOverview(); } = useMarketOverview();
const router = useRouter();
onMounted(() => { onMounted(() => {
void refresh(); void refresh();
}); });
function openDetail(label: string) { function openIndexDetail(code: string) {
// 详情页尚未立项,先保留明确入口并避免伪造导航地址。 void router.push({ name: 'market-index-detail', params: { code } });
console.info(`市场概览详情入口:${label}`); }
function openSectorDetail(code: string) {
void router.push({ name: 'market-sector-detail', params: { code } });
} }
</script> </script>
@@ -82,7 +97,7 @@ function openDetail(label: string) {
<IndexOverview <IndexOverview
:response="indices.response" :response="indices.response"
:loading="indices.status === 'loading'" :loading="indices.status === 'loading'"
@select="openDetail" @select="openIndexDetail"
/> />
</div> </div>
@@ -91,9 +106,29 @@ function openDetail(label: string) {
<SectorOverview <SectorOverview
:response="sectors.response" :response="sectors.response"
:loading="sectors.status === 'loading'" :loading="sectors.status === 'loading'"
@select="openDetail" @select="openSectorDetail"
/> />
<FundTrend :response="funds.response" :loading="funds.status === 'loading'" /> <FundTrend :response="funds.response" :loading="funds.status === 'loading'" />
</div>
<div class="overview-grid overview-grid-structure">
<SectorRankingPanel
:response="sectorRankings.response"
:loading="sectorRankings.status === 'loading'"
:category="sectorCategory"
@category-change="refreshSectorRankings"
@select="openSectorDetail"
/>
<CapitalFlowPanel
:response="capitalFlow.response"
:loading="capitalFlow.status === 'loading'"
:category="capitalFlowCategory"
@category-change="refreshCapitalFlow"
/>
<TurnoverTrendPanel
:response="turnoverTrend.response"
:loading="turnoverTrend.status === 'loading'"
/>
<ShortTermEmotion <ShortTermEmotion
:response="shortTermEmotion.response" :response="shortTermEmotion.response"
:loading="shortTermEmotion.status === 'loading'" :loading="shortTermEmotion.status === 'loading'"
@@ -0,0 +1,97 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import type { CapitalFlowResponse } from '@/types/market-overview';
type Category = 'industry' | 'concept' | 'board';
type Sort = 'inflow' | 'outflow';
const props = defineProps<{
response: CapitalFlowResponse | null;
loading: boolean;
category: Category;
}>();
const emit = defineEmits<{ 'category-change': [category: Category] }>();
const sort = ref<Sort>('inflow');
const categoryLabels: Record<Category, string> = {
industry: '行业',
concept: '题材',
board: '板块',
};
const items = computed(() => {
const source = props.response?.data?.items ?? [];
return [...source].sort((left, right) => {
const leftValue = left.netInflow ?? Number.NEGATIVE_INFINITY;
const rightValue = right.netInflow ?? Number.NEGATIVE_INFINITY;
return sort.value === 'inflow' ? rightValue - leftValue : leftValue - rightValue;
});
});
function formatFlow(value: number | null | undefined): string {
return value == null ? '未提供' : `${value > 0 ? '+' : ''}${value.toFixed(2)} 亿`;
}
</script>
<template>
<section class="overview-panel structure-panel" aria-labelledby="capital-flow-title">
<div class="panel-heading">
<div>
<span class="section-kicker">资金方向</span>
<h2 id="capital-flow-title">{{ categoryLabels[category] }}资金流向</h2>
</div>
<span class="panel-meta">净额口径</span>
</div>
<div class="structure-tabs" role="tablist" aria-label="资金分类">
<button
v-for="(label, value) in categoryLabels"
:key="value"
type="button"
:data-category="value"
:class="{ active: category === value }"
@click="emit('category-change', value as Category)"
>
{{ label }}
</button>
</div>
<div class="structure-sort" role="group" aria-label="资金排序">
<button type="button" :class="{ active: sort === 'inflow' }" @click="sort = 'inflow'">
净流入
</button>
<button type="button" :class="{ active: sort === 'outflow' }" @click="sort = 'outflow'">
净流出
</button>
</div>
<div v-if="loading" class="module-placeholder">正在读取资金流向…</div>
<div v-else-if="response?.status === 'unavailable'" class="module-unavailable">
<strong>暂不可用</strong>
<span>{{ response.warnings[0] }}</span>
</div>
<template v-else-if="items.length">
<div v-if="response?.status !== 'fresh'" class="module-warning">
<strong>{{ response?.status === 'partial' ? '部分数据' : '数据延迟' }}</strong>
<span>{{ response?.warnings[0] }}</span>
</div>
<div class="structure-list">
<div
v-for="item in items"
:key="item.code ?? item.name"
class="structure-row structure-row-static"
>
<span class="structure-name">{{ item.name }}</span>
<strong
class="structure-value"
:data-direction="(item.netInflow ?? 0) >= 0 ? 'up' : 'down'"
>
{{ formatFlow(item.netInflow) }}
</strong>
<small>{{
item.flowDefinition === 'unavailable' ? '口径未提供' : item.flowDefinition
}}</small>
</div>
</div>
</template>
<div v-else class="module-placeholder">暂无资金流向数据</div>
</section>
</template>
@@ -0,0 +1,114 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import CapitalFlowPanel from './CapitalFlowPanel.vue';
import SectorRankingPanel from './SectorRankingPanel.vue';
import TurnoverTrendPanel from './TurnoverTrendPanel.vue';
const meta = {
snapshotId: '20260825-103000',
asOf: '2026-08-25T10:30:00+08:00',
source: 'aktools' as const,
status: 'fresh' as const,
warnings: [],
};
describe('市场结构首屏面板', () => {
it('展示行业排行并支持类别切换和详情事件', async () => {
const wrapper = mount(SectorRankingPanel, {
props: {
loading: false,
category: 'industry' as const,
response: {
...meta,
data: {
category: 'industry' as const,
sort: 'rise' as const,
items: [
{
category: 'industry' as const,
code: 'BK1',
name: '半导体',
changePercent: 3.2,
turnoverAmount: 12.3,
advancingCount: 20,
decliningCount: 2,
leader: '测试股份',
tradeDate: '20260825',
},
],
},
},
},
});
expect(wrapper.text()).toContain('半导体');
expect(wrapper.text()).toContain('+3.20%');
await wrapper.get('[data-category="concept"]').trigger('click');
expect(wrapper.emitted('category-change')).toEqual([['concept']]);
await wrapper.get('[data-code="BK1"]').trigger('click');
expect(wrapper.emitted('select')).toEqual([['BK1']]);
});
it('资金流向缺失时显示 partial 警告且不显示零净流入', () => {
const wrapper = mount(CapitalFlowPanel, {
props: {
loading: false,
category: 'industry' as const,
response: {
...meta,
status: 'partial' as const,
warnings: ['部分资金流向字段不可用'],
data: {
category: 'industry' as const,
sort: 'inflow' as const,
items: [
{
category: 'industry' as const,
code: 'BK1',
name: '未知',
netInflow: null,
netOutflow: null,
turnoverAmount: 1,
tradeDate: null,
flowDefinition: 'unavailable',
},
],
},
},
},
});
expect(wrapper.text()).toContain('部分数据');
expect(wrapper.text()).toContain('部分资金流向字段不可用');
expect(wrapper.text()).toContain('未提供');
expect(wrapper.text()).not.toContain('0.00');
});
it('成交额趋势绘制非空 SVG 并展示单位', () => {
const wrapper = mount(TurnoverTrendPanel, {
props: {
loading: false,
response: {
...meta,
source: 'tdx' as const,
data: {
granularity: '5' as const,
unit: '亿元' as const,
marketCount: 3,
invalidPointCount: 0,
points: [
{ time: '2026-08-25 09:30:00', amount: 1, cumulativeAmount: 1 },
{ time: '2026-08-25 09:35:00', amount: 2, cumulativeAmount: 3 },
{ time: '2026-08-25 09:40:00', amount: 1.5, cumulativeAmount: 4.5 },
],
},
},
},
});
expect(wrapper.text()).toContain('亿元');
expect(wrapper.find('svg').exists()).toBe(true);
expect(wrapper.find('polyline').attributes('points')).not.toBe('');
});
});
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import type { SectorRankingsResponse } from '@/types/market-overview';
type Category = 'industry' | 'concept' | 'board';
type Sort = 'rise' | 'fall' | 'turnover';
const props = defineProps<{
response: SectorRankingsResponse | null;
loading: boolean;
category: Category;
}>();
const emit = defineEmits<{
select: [code: string];
'category-change': [category: Category];
}>();
const sort = ref<Sort>('rise');
const categoryLabels: Record<Category, string> = {
industry: '行业',
concept: '题材',
board: '板块',
};
const sortLabels: Record<Sort, string> = { rise: '涨幅', fall: '跌幅', turnover: '成交额' };
const items = computed(() => {
const source = props.response?.data?.items ?? [];
return [...source].sort((left, right) => {
const leftValue = sort.value === 'turnover' ? left.turnoverAmount : left.changePercent;
const rightValue = sort.value === 'turnover' ? right.turnoverAmount : right.changePercent;
const leftNumber = leftValue ?? Number.NEGATIVE_INFINITY;
const rightNumber = rightValue ?? Number.NEGATIVE_INFINITY;
const difference = sort.value === 'fall' ? leftNumber - rightNumber : rightNumber - leftNumber;
return difference || left.name.localeCompare(right.name, 'zh-CN');
});
});
function formatPercent(value: number | null | undefined): string {
return value == null ? '—' : `${value > 0 ? '+' : ''}${value.toFixed(2)}%`;
}
function formatAmount(value: number | null | undefined): string {
return value == null ? '—' : `${value.toFixed(2)} 亿`;
}
</script>
<template>
<section class="overview-panel structure-panel" aria-labelledby="sector-ranking-title">
<div class="panel-heading">
<div>
<span class="section-kicker">结构扫描</span>
<h2 id="sector-ranking-title">{{ categoryLabels[category] }}强弱排行</h2>
</div>
<span class="panel-meta">Top {{ response?.data?.items.length ?? 0 }}</span>
</div>
<div class="structure-tabs" role="tablist" aria-label="板块分类">
<button
v-for="(label, value) in categoryLabels"
:key="value"
type="button"
:data-category="value"
:class="{ active: category === value }"
@click="emit('category-change', value as Category)"
>
{{ label }}
</button>
</div>
<div class="structure-sort" role="group" aria-label="排行口径">
<button
v-for="(label, value) in sortLabels"
:key="value"
type="button"
:class="{ active: sort === value }"
@click="sort = value as Sort"
>
{{ label }}
</button>
</div>
<div v-if="loading" class="module-placeholder">正在读取{{ categoryLabels[category] }}排行…</div>
<div v-else-if="response?.status === 'unavailable'" class="module-unavailable">
<strong>暂不可用</strong>
<span>{{ response.warnings[0] }}</span>
</div>
<template v-else-if="items.length">
<div v-if="response?.status !== 'fresh'" class="module-warning">
<strong>{{ response?.status === 'partial' ? '部分数据' : '数据延迟' }}</strong>
<span>{{ response?.warnings[0] }}</span>
</div>
<div class="structure-list">
<button
v-for="item in items"
:key="item.code"
type="button"
class="structure-row"
:data-code="item.code"
:title="`进入${item.name}详细分析`"
@click="emit('select', item.code)"
>
<span class="structure-name">{{ item.name }}</span>
<span class="structure-value" data-direction="up">{{
formatPercent(item.changePercent)
}}</span>
<small>{{
sort === 'turnover'
? formatAmount(item.turnoverAmount)
: formatAmount(item.turnoverAmount)
}}</small>
</button>
</div>
</template>
<div v-else class="module-placeholder">暂无{{ categoryLabels[category] }}排行数据</div>
</section>
</template>
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { TurnoverTrendResponse } from '@/types/market-overview';
const props = defineProps<{ response: TurnoverTrendResponse | null; loading: boolean }>();
const chartPoints = computed(() => {
const points = props.response?.data?.points ?? [];
if (!points.length) return '';
const max = Math.max(...points.map((point) => point.cumulativeAmount), 1);
const width = 620;
const height = 150;
return points
.map((point, index) => {
const x = points.length === 1 ? width / 2 : (index / (points.length - 1)) * width;
const y = height - (point.cumulativeAmount / max) * height;
return `${x.toFixed(2)},${y.toFixed(2)}`;
})
.join(' ');
});
const latest = computed(() => props.response?.data?.points.at(-1)?.cumulativeAmount ?? null);
</script>
<template>
<section
class="overview-panel structure-panel turnover-panel"
aria-labelledby="turnover-trend-title"
>
<div class="panel-heading">
<div>
<span class="section-kicker">量能观察</span>
<h2 id="turnover-trend-title">全天成交额趋势</h2>
</div>
<span class="panel-meta">{{ response?.data?.granularity ?? '—' }} 分钟</span>
</div>
<div v-if="loading" class="module-placeholder">正在读取成交额趋势…</div>
<div v-else-if="response?.status === 'unavailable'" class="module-unavailable">
<strong>暂不可用</strong>
<span>{{ response.warnings[0] }}</span>
</div>
<template v-else-if="response?.data?.points.length">
<div v-if="response.status !== 'fresh'" class="module-warning">
<strong>{{ response.status === 'partial' ? '部分数据' : '数据延迟' }}</strong>
<span>{{ response.warnings[0] }}</span>
</div>
<div class="turnover-summary">
<strong>{{ latest?.toFixed(2) ?? '—' }}</strong>
<span>{{ response.data.unit }}累计成交额</span>
</div>
<svg class="turnover-chart" viewBox="0 0 640 180" role="img" aria-label="全天累计成交额趋势">
<polyline
:points="chartPoints"
fill="none"
stroke="#167d82"
stroke-width="3"
vector-effect="non-scaling-stroke"
/>
</svg>
</template>
<div v-else class="module-placeholder">暂无成交额趋势数据</div>
</section>
</template>
@@ -0,0 +1,47 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import MarketDetailChart from './MarketDetailChart.vue';
describe('市场详情 K 线图', () => {
it('在有序 K 线输入下渲染固定尺寸 SVG 路径', () => {
const wrapper = mount(MarketDetailChart, {
props: {
series: [
{
time: '20260817',
open: 100,
high: 103,
low: 99,
close: 102,
amount: 1000,
volume: 100,
},
{
time: '20260818',
open: 102,
high: 106,
low: 101,
close: 105,
amount: 1200,
volume: 120,
},
],
availableViews: { kline: true, intraday: false },
},
});
expect(wrapper.get('svg').attributes('viewBox')).toBe('0 0 720 260');
expect(wrapper.get('path').attributes('d')).toMatch(/^M /);
expect(wrapper.text()).toContain('日 K 线');
});
it('无图表数据时显示边界说明而不是空白画布', () => {
const wrapper = mount(MarketDetailChart, {
props: { series: [], availableViews: { kline: false, intraday: false } },
});
expect(wrapper.find('svg').exists()).toBe(false);
expect(wrapper.text()).toContain('当前没有可用的 K 线数据');
});
});
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { MarketDetailSeries } from './market-detail';
const props = defineProps<{
series: MarketDetailSeries;
availableViews: { kline: boolean; intraday: boolean };
}>();
const width = 720;
const height = 260;
const padding = 24;
const path = computed(() => {
if (!props.series.length || !props.availableViews.kline) return '';
const closes = props.series.map((point) => point.close);
const min = Math.min(...closes);
const max = Math.max(...closes);
const range = max - min || 1;
const xStep = (width - padding * 2) / Math.max(props.series.length - 1, 1);
return props.series
.map((point, index) => {
const x = padding + index * xStep;
const y = height - padding - ((point.close - min) / range) * (height - padding * 2);
return `${index === 0 ? 'M' : 'L'} ${x.toFixed(2)} ${y.toFixed(2)}`;
})
.join(' ');
});
</script>
<template>
<section class="detail-chart-panel" aria-labelledby="detail-kline-title">
<div class="detail-section-heading">
<div>
<span class="section-kicker">走势</span>
<h2 id="detail-kline-title">日 K 线</h2>
</div>
<span class="detail-chart-note">TDX · {{ series.length }} 点</span>
</div>
<div v-if="!path" class="detail-chart-empty">当前没有可用的 K 线数据</div>
<svg
v-else
class="detail-chart"
viewBox="0 0 720 260"
role="img"
aria-label="日 K 线收盘价走势"
>
<line x1="24" y1="236" x2="696" y2="236" class="detail-chart-axis" />
<path :d="path" class="detail-chart-line" />
</svg>
<p v-if="!availableViews.intraday" class="detail-chart-boundary">当前数据源未提供分时数据</p>
</section>
</template>
@@ -0,0 +1,115 @@
import { mount } from '@vue/test-utils';
import { createMemoryHistory, createRouter } from 'vue-router';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import MarketDetailPage from './MarketDetailPage.vue';
const { getIndexDetail, getSectorDetail } = vi.hoisted(() => ({
getIndexDetail: vi.fn(),
getSectorDetail: vi.fn(),
}));
vi.mock('@/api/market-overview/detail', () => ({
default: { getIndexDetail, getSectorDetail },
}));
function createTestRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{
path: '/market-overview/indices/:code',
component: MarketDetailPage,
meta: { detailType: 'index' },
},
],
});
}
const freshResponse = {
snapshotId: '20260818-030000',
asOf: '2026-08-18T00:00:00.000Z',
source: 'tdx' as const,
status: 'fresh' as const,
warnings: [],
data: {
instrument: { code: '000001.SH', name: '上证指数', type: 'index' as const },
quote: { price: 3380, changePercent: 0.89, direction: 'up' as const, tradeDate: '20260818' },
series: [
{
time: '20260817',
open: 3350,
high: 3385,
low: 3340,
close: 3350,
amount: 100,
volume: 1000,
},
{
time: '20260818',
open: 3370,
high: 3400,
low: 3360,
close: 3380,
amount: 120,
volume: 1100,
},
],
availableViews: { kline: true, intraday: false },
},
};
describe('市场详情页', () => {
beforeEach(() => {
getIndexDetail.mockReset();
getSectorDetail.mockReset();
});
it('按路由加载指数详情并显示上海时间、K 线和分时边界', async () => {
getIndexDetail.mockResolvedValue(freshResponse);
const router = createTestRouter();
await router.push('/market-overview/indices/000001.SH');
await router.isReady();
const wrapper = mount(MarketDetailPage, { global: { plugins: [router] } });
await vi.waitFor(() =>
expect(getIndexDetail).toHaveBeenCalledWith('000001.SH', { period: '1d', count: 120 }),
);
await vi.waitFor(() => expect(wrapper.text()).toContain('上证指数'));
expect(wrapper.text()).toContain('上证指数');
expect(wrapper.text()).toContain('2026-08-18 08:00:00');
expect(wrapper.text()).toContain('日 K 线');
expect(wrapper.text()).toContain('当前数据源未提供分时数据');
});
it('路由代码格式非法时不调用 API 并显示不可用边界', async () => {
const router = createTestRouter();
await router.push('/market-overview/indices/not-a-code');
await router.isReady();
const wrapper = mount(MarketDetailPage, { global: { plugins: [router] } });
expect(getIndexDetail).not.toHaveBeenCalled();
await vi.waitFor(() => expect(wrapper.text()).toContain('详情代码不可用'));
});
it('TDX 返回 unavailable 时显示 warning 而不是伪造图表', async () => {
getIndexDetail.mockResolvedValue({
...freshResponse,
status: 'unavailable',
data: null,
warnings: ['指数数据不可用'],
});
const router = createTestRouter();
await router.push('/market-overview/indices/000001.SH');
await router.isReady();
const wrapper = mount(MarketDetailPage, { global: { plugins: [router] } });
await vi.waitFor(() => expect(getIndexDetail).toHaveBeenCalled());
await vi.waitFor(() => expect(wrapper.text()).toContain('指数数据不可用'));
expect(wrapper.text()).toContain('指数数据不可用');
expect(wrapper.find('svg').exists()).toBe(false);
});
});
@@ -0,0 +1,149 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import marketDetailApi from '@/api/market-overview/detail';
import { formatShanghaiDateTime } from '@/utils/datetime';
import MarketDetailChart from './MarketDetailChart.vue';
import {
formatPercent,
formatTradeDate,
isMarketCode,
isMarketDetailType,
statusLabel,
type MarketDetailResponse,
type MarketDetailType,
} from './market-detail';
type PageStatus = 'idle' | 'loading' | 'ready' | 'error' | 'invalid';
const route = useRoute();
const router = useRouter();
const response = ref<MarketDetailResponse | null>(null);
const pageStatus = ref<PageStatus>('idle');
const detailType = computed<MarketDetailType | null>(() => {
const routeType = route.params.type;
if (isMarketDetailType(routeType)) return routeType;
return isMarketDetailType(route.meta.detailType) ? route.meta.detailType : null;
});
const code = computed(() => (typeof route.params.code === 'string' ? route.params.code : ''));
const isValidRoute = computed(() => detailType.value !== null && isMarketCode(code.value));
const data = computed(() => response.value?.data ?? null);
function createInvalidState(): void {
response.value = null;
pageStatus.value = 'invalid';
}
async function loadDetail(): Promise<void> {
if (!isValidRoute.value || !detailType.value) {
createInvalidState();
return;
}
pageStatus.value = 'loading';
response.value = null;
try {
response.value =
detailType.value === 'index'
? await marketDetailApi.getIndexDetail(code.value, { period: '1d', count: 120 })
: await marketDetailApi.getSectorDetail(code.value, { period: '1d', count: 120 });
pageStatus.value = 'ready';
} catch {
response.value = null;
pageStatus.value = 'error';
}
}
function goBack(): void {
void router.push('/');
}
watch([detailType, code], () => void loadDetail());
onMounted(() => void loadDetail());
</script>
<template>
<div class="market-detail-page">
<div class="detail-toolbar">
<button type="button" class="quiet-button" title="返回市场概览" @click="goBack">
← 返回概览
</button>
<span class="detail-toolbar-context">只读行情 · TDX</span>
</div>
<section v-if="pageStatus === 'loading'" class="detail-state-panel" aria-live="polite">
正在读取详情行情…
</section>
<section v-else-if="pageStatus === 'invalid'" class="detail-state-panel detail-state-warning">
<strong>详情代码不可用</strong>
<span>请从市场概览中选择已登记的指数或一级行业。</span>
</section>
<section v-else-if="pageStatus === 'error'" class="detail-state-panel detail-state-warning">
<strong>详情请求失败</strong>
<span>API 暂时不可用,请返回概览后重试。</span>
</section>
<template v-else-if="response">
<section class="detail-hero-panel">
<div>
<span class="section-kicker">{{
detailType === 'sector' ? '板块详情' : '指数详情'
}}</span>
<h1>{{ data?.instrument.name ?? code }}</h1>
<p>{{ data?.instrument.code ?? code }}</p>
</div>
<div class="detail-meta">
<span>来源:{{ response.source.toUpperCase() }}</span>
<span>更新时间:{{ formatShanghaiDateTime(response.asOf) }}</span>
<span>交易日:{{ formatTradeDate(data?.quote.tradeDate) }}</span>
<strong :data-status="response.status">{{ statusLabel(response.status) }}</strong>
</div>
</section>
<section v-if="!data" class="detail-state-panel detail-state-warning">
<strong>{{ response.warnings[0] ?? '行情数据不可用' }}</strong>
<span>当前页面不展示伪造的图表或报价。</span>
</section>
<template v-else>
<section class="detail-quote-panel" aria-label="核心行情">
<div>
<span>最新收盘价</span>
<strong>{{ data.quote.price.toFixed(2) }}</strong>
</div>
<div>
<span>涨跌幅</span>
<strong :data-direction="data.quote.direction">{{
formatPercent(data.quote.changePercent ?? null)
}}</strong>
</div>
<div>
<span>有效 K 线</span>
<strong>{{ data.series.length }} 点</strong>
</div>
</section>
<div v-if="response.status !== 'fresh'" class="detail-warning">
<strong>{{ statusLabel(response.status) }}</strong>
<span>{{ response.warnings[0] ?? '部分详情数据不可用' }}</span>
</div>
<MarketDetailChart :series="data.series" :available-views="data.availableViews" />
<section class="detail-facts-panel" aria-labelledby="detail-facts-title">
<div class="detail-section-heading">
<div>
<span class="section-kicker">复盘摘要</span>
<h2 id="detail-facts-title">事实数据</h2>
</div>
</div>
<p>
{{ data.instrument.name }}最近交易日收盘价为 {{ data.quote.price.toFixed(2) }}, 涨跌幅
{{ formatPercent(data.quote.changePercent ?? null) }}。本页只展示 TDX
行情和规则计算结果。
</p>
</section>
</template>
</template>
</div>
</template>
@@ -0,0 +1,35 @@
import type { components } from '@/api/generated/schema';
export type MarketDetailType = 'index' | 'sector';
export type MarketDetailResponse =
| components['schemas']['IndexDetailResponseDto']
| components['schemas']['SectorDetailResponseDto'];
export type MarketDetailData = NonNullable<MarketDetailResponse['data']>;
export type MarketDetailSeries = MarketDetailData['series'];
export function isMarketDetailType(value: unknown): value is MarketDetailType {
return value === 'index' || value === 'sector';
}
export function isMarketCode(value: unknown): value is string {
return typeof value === 'string' && /^\d{6}\.(SH|SZ|BJ)$/.test(value);
}
export function formatTradeDate(value: string | undefined): string {
return value && /^\d{8}$/.test(value)
? `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}`
: '—';
}
export function formatPercent(value: number | null): string {
return value === null ? '—' : `${value > 0 ? '+' : ''}${value.toFixed(2)}%`;
}
export function statusLabel(status: MarketDetailResponse['status']): string {
return {
fresh: '数据新鲜',
delayed: '交易日延迟',
partial: '部分数据',
unavailable: '数据不可用',
}[status];
}
@@ -19,6 +19,9 @@ describe('市场概览状态', () => {
getBreadth: vi.fn().mockResolvedValue(response), getBreadth: vi.fn().mockResolvedValue(response),
getSectors: vi.fn().mockResolvedValue(response), getSectors: vi.fn().mockResolvedValue(response),
getFunds: vi.fn().mockResolvedValue(response), getFunds: vi.fn().mockResolvedValue(response),
getSectorRankings: vi.fn().mockResolvedValue(response),
getCapitalFlow: vi.fn().mockResolvedValue(response),
getTurnoverTrend: vi.fn().mockResolvedValue(response),
getShortTermEmotion: vi.fn().mockResolvedValue(response), getShortTermEmotion: vi.fn().mockResolvedValue(response),
getShortTermConclusion: vi.fn().mockResolvedValue(response), getShortTermConclusion: vi.fn().mockResolvedValue(response),
}; };
@@ -32,6 +35,9 @@ describe('市场概览状态', () => {
expect(state.isLoading.value).toBe(false); expect(state.isLoading.value).toBe(false);
expect(state.shortTermEmotion.value.status).toBe('ready'); expect(state.shortTermEmotion.value.status).toBe('ready');
expect(state.shortTermConclusion.value.status).toBe('ready'); expect(state.shortTermConclusion.value.status).toBe('ready');
expect(state.sectorRankings.value.status).toBe('ready');
expect(state.capitalFlow.value.status).toBe('ready');
expect(state.turnoverTrend.value.status).toBe('ready');
}); });
it('分析偏好写入本地存储并可恢复', () => { it('分析偏好写入本地存储并可恢复', () => {
@@ -44,4 +50,28 @@ describe('市场概览状态', () => {
expect(second.analysisHorizon.value).toBe('短线'); expect(second.analysisHorizon.value).toBe('短线');
expect(second.analysisEngine.value).toBe('混合'); expect(second.analysisEngine.value).toBe('混合');
}); });
it('切换行业结构分类时重新请求对应数据', async () => {
const api = {
getSectorRankings: vi.fn().mockResolvedValue(response),
getCapitalFlow: vi.fn().mockResolvedValue(response),
};
const state = useMarketOverview(api as never);
await state.refreshSectorRankings('concept');
await state.refreshCapitalFlow('board');
expect(api.getSectorRankings).toHaveBeenCalledWith({
category: 'concept',
sort: 'rise',
limit: 10,
});
expect(api.getCapitalFlow).toHaveBeenCalledWith({
category: 'board',
sort: 'inflow',
limit: 10,
});
expect(state.sectorCategory.value).toBe('concept');
expect(state.capitalFlowCategory.value).toBe('board');
});
}); });
@@ -7,6 +7,9 @@ import type {
IndicesResponse, IndicesResponse,
OverviewModuleStatus, OverviewModuleStatus,
SectorsResponse, SectorsResponse,
SectorRankingsResponse,
CapitalFlowResponse,
TurnoverTrendResponse,
ShortTermConclusionResponse, ShortTermConclusionResponse,
ShortTermEmotionResponse, ShortTermEmotionResponse,
SummaryResponse, SummaryResponse,
@@ -19,7 +22,12 @@ type MarketResponse =
| SectorsResponse | SectorsResponse
| FundsResponse | FundsResponse
| ShortTermEmotionResponse | ShortTermEmotionResponse
| ShortTermConclusionResponse; | ShortTermConclusionResponse
| SectorRankingsResponse
| CapitalFlowResponse
| TurnoverTrendResponse;
export type MarketStructureCategory = 'industry' | 'concept' | 'board';
export interface OverviewModuleState<TResponse extends MarketResponse> { export interface OverviewModuleState<TResponse extends MarketResponse> {
status: OverviewModuleStatus; status: OverviewModuleStatus;
@@ -54,6 +62,11 @@ export function useMarketOverview(
const breadth = ref<OverviewModuleState<BreadthResponse>>(createState()); const breadth = ref<OverviewModuleState<BreadthResponse>>(createState());
const sectors = ref<OverviewModuleState<SectorsResponse>>(createState()); const sectors = ref<OverviewModuleState<SectorsResponse>>(createState());
const funds = ref<OverviewModuleState<FundsResponse>>(createState()); const funds = ref<OverviewModuleState<FundsResponse>>(createState());
const sectorRankings = ref<OverviewModuleState<SectorRankingsResponse>>(createState());
const capitalFlow = ref<OverviewModuleState<CapitalFlowResponse>>(createState());
const turnoverTrend = ref<OverviewModuleState<TurnoverTrendResponse>>(createState());
const sectorCategory = ref<MarketStructureCategory>('industry');
const capitalFlowCategory = ref<MarketStructureCategory>('industry');
const shortTermEmotion = ref<OverviewModuleState<ShortTermEmotionResponse>>(createState()); const shortTermEmotion = ref<OverviewModuleState<ShortTermEmotionResponse>>(createState());
const shortTermConclusion = ref<OverviewModuleState<ShortTermConclusionResponse>>(createState()); const shortTermConclusion = ref<OverviewModuleState<ShortTermConclusionResponse>>(createState());
const analysisHorizon = ref(readPreference('a-share-analysis:overview-horizon', '综合')); const analysisHorizon = ref(readPreference('a-share-analysis:overview-horizon', '综合'));
@@ -65,6 +78,9 @@ export function useMarketOverview(
breadth.value, breadth.value,
sectors.value, sectors.value,
funds.value, funds.value,
sectorRankings.value,
capitalFlow.value,
turnoverTrend.value,
shortTermEmotion.value, shortTermEmotion.value,
shortTermConclusion.value, shortTermConclusion.value,
].some((module) => module.status === 'loading'), ].some((module) => module.status === 'loading'),
@@ -76,6 +92,9 @@ export function useMarketOverview(
breadth.value.status = 'loading'; breadth.value.status = 'loading';
sectors.value.status = 'loading'; sectors.value.status = 'loading';
funds.value.status = 'loading'; funds.value.status = 'loading';
sectorRankings.value.status = 'loading';
capitalFlow.value.status = 'loading';
turnoverTrend.value.status = 'loading';
shortTermEmotion.value.status = 'loading'; shortTermEmotion.value.status = 'loading';
shortTermConclusion.value.status = 'loading'; shortTermConclusion.value.status = 'loading';
} }
@@ -93,6 +112,22 @@ export function useMarketOverview(
} }
} }
async function refreshSectorRankings(category = sectorCategory.value): Promise<void> {
sectorCategory.value = category;
sectorRankings.value.status = 'loading';
await loadModule(sectorRankings, () =>
api.getSectorRankings({ category, sort: 'rise', limit: 10 }),
);
}
async function refreshCapitalFlow(category = capitalFlowCategory.value): Promise<void> {
capitalFlowCategory.value = category;
capitalFlow.value.status = 'loading';
await loadModule(capitalFlow, () =>
api.getCapitalFlow({ category, sort: 'inflow', limit: 10 }),
);
}
async function refresh(): Promise<void> { async function refresh(): Promise<void> {
setLoading(); setLoading();
await Promise.all([ await Promise.all([
@@ -101,6 +136,9 @@ export function useMarketOverview(
loadModule(breadth, api.getBreadth), loadModule(breadth, api.getBreadth),
loadModule(sectors, api.getSectors), loadModule(sectors, api.getSectors),
loadModule(funds, api.getFunds), loadModule(funds, api.getFunds),
refreshSectorRankings(),
refreshCapitalFlow(),
loadModule(turnoverTrend, () => api.getTurnoverTrend({ interval: '5' })),
loadModule(shortTermEmotion, api.getShortTermEmotion), loadModule(shortTermEmotion, api.getShortTermEmotion),
loadModule(shortTermConclusion, api.getShortTermConclusion), loadModule(shortTermConclusion, api.getShortTermConclusion),
]); ]);
@@ -122,12 +160,19 @@ export function useMarketOverview(
breadth, breadth,
sectors, sectors,
funds, funds,
sectorRankings,
capitalFlow,
turnoverTrend,
shortTermEmotion, shortTermEmotion,
shortTermConclusion, shortTermConclusion,
sectorCategory,
capitalFlowCategory,
isLoading, isLoading,
analysisHorizon, analysisHorizon,
analysisEngine, analysisEngine,
refresh, refresh,
refreshSectorRankings,
refreshCapitalFlow,
setAnalysisHorizon, setAnalysisHorizon,
setAnalysisEngine, setAnalysisEngine,
}; };