Compare commits

...
45 changed files with 3065 additions and 8 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** (3793 symbols, 5473 relationships, 115 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,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}数据不可用`;
}
}
@@ -7,6 +7,8 @@ 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';
@Module({}) @Module({})
export class MarketOverviewModule { export class MarketOverviewModule {
@@ -14,9 +16,10 @@ 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],
providers: [ providers: [
MarketOverviewService, MarketOverviewService,
MarketDetailService,
{ 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) },
@@ -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);
});
});
+196
View File
@@ -40,6 +40,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" },
@@ -101,6 +141,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"], "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" },
@@ -152,6 +226,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"], "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" },
@@ -401,6 +509,50 @@
"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/sectors": { "/api/v1/market-overview/sectors": {
"get": { "get": {
"operationId": "MarketOverviewController_getSectors", "operationId": "MarketOverviewController_getSectors",
@@ -418,6 +570,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",
+9 -1
View File
@@ -9,6 +9,7 @@ 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 { import {
HealthService, HealthService,
@@ -19,6 +20,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,10 +29,16 @@ 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,
],
providers: [ providers: [
HealthService, HealthService,
MarketOverviewService, MarketOverviewService,
MarketDetailService,
{ provide: TDX_CLIENT_PORT, useValue: { request: async () => ({}) } }, { provide: TDX_CLIENT_PORT, useValue: { request: async () => ({}) } },
ShortTermMarketService, ShortTermMarketService,
{ {
+144
View File
@@ -68,6 +68,22 @@ 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/sectors': { '/api/v1/market-overview/sectors': {
parameters: { parameters: {
query?: never; query?: never;
@@ -84,6 +100,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;
@@ -157,6 +189,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;
@@ -194,6 +254,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';
/** @enum {string} */
status: 'fresh' | 'delayed' | 'partial' | 'unavailable';
warnings: string[];
};
IndexQuoteDto: { IndexQuoteDto: {
changePercent?: number | null; changePercent?: number | null;
code: string; code: string;
@@ -220,6 +297,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';
/** @enum {string} */
status: 'fresh' | 'delayed' | 'partial' | 'unavailable';
warnings: string[];
};
SectorQuoteDto: { SectorQuoteDto: {
changePercent: number; changePercent: number;
code: string; code: string;
@@ -405,6 +499,31 @@ 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'];
};
};
};
};
MarketOverviewController_getSectors: { MarketOverviewController_getSectors: {
parameters: { parameters: {
query?: never; query?: never;
@@ -424,6 +543,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;
@@ -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;
+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 的路由实例,使浏览器运行与单元测试共享同一路由表。 */
+244
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;
@@ -686,4 +906,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;
}
} }
@@ -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';
@@ -25,14 +26,18 @@ const {
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 +87,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,7 +96,7 @@ 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'" />
<ShortTermEmotion <ShortTermEmotion
@@ -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];
}