Compare commits

...
49 changed files with 3085 additions and 20 deletions
+1 -1
View File
@@ -29,7 +29,7 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
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.
This project is indexed by GitNexus as **a-share-analysis** (4266 symbols, 6330 relationships, 147 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
@@ -0,0 +1,25 @@
# FEAT-MO-005 数据源能力探针
## 结论
收盘后于 2026-08-25(Asia/Shanghai)执行两次本机只读探测。TDX 三市场日线可用,`Date` 与 `Amount` 均等长;1/5/15 分钟请求业务成功但序列为空。AKTools 五个候选中,市场资金流曾可用一次、随后 HTTP 500,归类为 `partial`;其余候选为 HTTP 500 或超时,归类为 `unavailable`。
两次均在非交易时段,最后一条记录仅为收盘后最后记录,不能推断盘中新鲜度。交易时段观测尚未执行,不能将任一来源标为实时。
## 脱敏能力表
| 来源/接口 | 分类 | 两次收盘后摘要 | 字段与时间结论 |
| --- | --- | --- | --- |
| TDX `get_market_data`,三市场 `1d` | `usable` | 30 行;63-148ms | `Date`/`Amount` 对齐;首末日期为 20260714/20260824;非分时。 |
| TDX `get_market_data`,三市场 `1m`/`5m`/`15m` | `partial` | 0 行;63-193ms | 无时间序列,不能形成盘中成交额趋势。 |
| AKTools `stock_board_industry_spot_em` | `unavailable` | HTTP 500;约 5.2-5.5 秒 | 未取得字段或时间。 |
| AKTools `stock_board_concept_spot_em` | `unavailable` | 10 秒 timeout | 未取得字段或时间。 |
| AKTools `stock_sector_fund_flow_rank` | `unavailable` | HTTP 500;117-144ms | 未取得字段或时间。 |
| AKTools `stock_market_fund_flow` | `partial` | 第一次 120 行/172ms,第二次 HTTP 500/130ms | 可用时含日期和各档净流入净额/净占比;不可作为稳定排行源。 |
| AKTools `stock_zh_a_spot_em` | `unavailable` | HTTP 500;约 5.6-5.9 秒 | 未取得字段或时间。 |
未保存原始行、请求体、凭据、上游错误内容或非本机地址。HTTP 500 与 timeout 作为不同短错误类别记录。
## 后续准入
Tasks 2-4 只能按 `design.md` 中的映射实现明确的不可用/部分可用降级;不得接入行业、题材、板块排行或盘中成交额趋势。交易时段需要至少一次复测,以验证最后时间与实际时段的新鲜度。
@@ -0,0 +1,27 @@
# FEAT-MO-005 设计说明
## 方案
探针固定请求五个 AKTools 候选和 TDX `get_market_data` 的三只指数、四种周期。输出仅允许状态、耗时、行数、字段名、首末时间与短错误类别;原始行、请求体、凭据和非本机 URL 不记录。
后续结构模块以 TDX 日线的 `Date`、`Amount` 作为全市场日度成交额快照依据。分钟周期为空时,成交额趋势不得伪装为盘中分时;行业、题材与排行资金流接口未通过时,API 只返回不可用状态。
## 字段定义与来源映射
| 指标 | 字段口径 | 映射结论 |
| --- | --- | --- |
| 全市场日度成交额 | 三市场同交易日 `Amount` 相加,TDX 单位万元转亿元 | 已验证快照派生(TDX 主来源) |
| 全市场盘中成交额趋势 | `Date` 与 `Amount` 对齐的 1/5/15 分钟序列 | 延期为不可用,当前分钟序列为空 |
| 行业、题材、板块排行 | 上游可证明的名称、涨跌幅、成交额字段 | 延期为不可用,候选接口失败 |
| 行业、题材、板块资金流排行 | 上游明确的净流入字段和定义 | 延期为不可用,排行接口失败 |
| 全市场资金流历史 | AKTools `主力/超大/大/中/小单净流入-净额` 等明示字段 | AKTools 补充源,当前为 `partial`,不能用于排行 |
## 安全策略与评估
资产为本机桥接可返回的行情、配置中的 TDX 地址和任何凭据。信任边界位于 Node 探针与本机回环服务之间:参数被固定白名单限制,URL 仅接受 `127.0.0.1` 固定端口,10 秒超时和 2 MiB 响应上限限制上游异常。输出仅为脱敏摘要,错误不包含上游响应。认证、授权、SQL、XSS、CSRF 与 CORS 不适用于该无 HTTP 入站、无持久化、无浏览器页面的本地 CLI;其边界是后续 Nest API 必须重新验证。
残余风险是本机桥接和免费上游不稳定,且未验证盘中延迟、授权和再分发要求;任何生产接入前必须单独审查。
## 性能目标与评估
单次探针固定为 17 个请求,单请求上限 10 秒、响应上限 2 MiB,不保存载荷。收盘后两次观测中 TDX 单请求为 63-193ms,AKTools 出现 117ms 至 10,004ms 的失败/超时,故不把本地观测视为容量结论;本低频开发工具不做负载测试。
@@ -0,0 +1,52 @@
# FEAT-MO-005 开发总结
- 功能编号:FEAT-MO-005
- 日期:2026-08-25
- 发布状态:未发布,当前分支已推送 Gitea
## 原始开发需求
综合概览需要覆盖全部板块、题材和行业的涨跌幅排行、资金流入/流出排行,以及全天成交额趋势图;用户可点击进入详细分析,分时 K 线不作为本轮首页重点。
## 需求与范围变化
- 根据数据源探针结果,行业/题材/资金流保留固定 AKTools 适配,但当前不可用时必须保守降级。
- 独立 API 继续按模块拆分,没有把整个首屏合并成一个聚合接口。
- 板块分类未验证独立上游端点,因此保留切换入口但返回 `unavailable`。
- 没有引入数据库、Redis、RocketMQ、AI 或用户体系。
## 实际完成需求
| 需求 | 状态 | 结果 |
| --- | --- | --- |
| 行业/题材/板块强弱排行 | 已完成 | 新增 `SectorRankingPanel`,支持分类、涨跌/成交额排序和详情事件。 |
| 资金流向排行 | 已完成 | 新增 `CapitalFlowPanel`,只展示上游明示净额,缺失值显示未提供。 |
| 全天成交额趋势 | 已完成 | 新增 `TurnoverTrendPanel`,支持累计亿元和 SVG 趋势线。 |
| 前端分类真实刷新 | 已完成 | composable 分类切换会重新请求对应 API。 |
| 既有模块兼容 | 已完成 | 指数、宽度、板块概览、量能和短线情绪均保留。 |
## 修复缺陷
E2E 首次运行发现 `MarketStructureController` 未使用显式 Nest 服务注入,三个新路由因此返回 400。已增加 `@Inject(MarketStructureService)`,服务端回归测试和 E2E 均通过。
## 未完成与延期项
AKTools 交易时段实时性、行业/题材/资金流生产稳定性和独立板块数据源仍待验证;本轮不承诺实时生产数据。详情页 K 线和 AI 分析不在本功能范围。
## 关键提交
| 提交 | 内容 | 推送 |
| --- | --- | --- |
| `50b5266`..`b6a9f40` | 数据源探针、领域映射和契约 | 已推送 |
| `8174f3f`..`ad40fff` | 三路结构与成交额 API | 已推送 |
| `b95b9e5` | Web 数据层 | 已推送 |
| `64a9f2e` | 首屏面板、响应式样式、注入修复 | 已推送 |
## 验证证据
- Web:14 个 Vitest 文件、39 个测试;类型检查通过。
- Server:21 个 Vitest 文件、110 个测试及 Node 冒烟通过。
- 根级 lint、Prettier、typecheck、test、build 通过;lint 仅有既有 Vue 风格 warning,无 error。
- Playwright:桌面和 390px 移动视口共 4 项通过,无横向溢出和运行时错误。
- 1440px/390px 截图已检查;AKTools 不可用状态展示符合降级设计。
- GitNexus 提交前检测:中风险,10 文件/47 符号/1 条刷新流程,已完成回归覆盖。
@@ -0,0 +1,23 @@
# GitNexus 开发后基线
## GitNexus 状态
```text
Repository: /Users/citrons/Documents/Codex/2026-08-12/wo-x/a-share-analysis
Indexed: 8/25/2026, 2:38:36 AM
Indexed commit: 21393ef
Current commit: 21393ef
Status: ✅ up-to-date
```
## Git 已跟踪变更摘要
```text
(没有未提交差异)
```
## Git 工作区摘要
```text
?? "SRMS3.6.0\345\270\270\350\247\201\351\227\256\351\242\230\346\265\213\350\257\204\351\242\230.2csv"
```
@@ -0,0 +1,26 @@
# GitNexus 开发前基线
- 功能编号:FEAT-MO-005
- 记录时间:2026年8月25日 GMT+8 01:15:39
- 分支:codex/feat-mo-005-market-structure-turnover
- 基线提交:4f5a56df3a71
## 工作区
```text
?? "SRMS3.6.0\345\270\270\350\247\201\351\227\256\351\242\230\346\265\213\350\257\204\351\242\230.2csv"
```
## GitNexus 状态
```text
Repository: /Users/citrons/Documents/Codex/2026-08-12/wo-x/a-share-analysis
Indexed: 8/25/2026, 1:15:38 AM
Indexed commit: 4f5a56d
Current commit: 4f5a56d
Status: ✅ up-to-date
```
## 后续人工分析
在修改代码前,将 GitNexus query、context、impact 的结论写入 `gitnexus/impact-plan.md`。高风险结果必须先获得用户确认。
@@ -0,0 +1,12 @@
# GitNexus 前后对比
| 项目 | 开发前 | 开发后 | 判断 |
| --- | --- | --- | --- |
| 索引节点 | 4f5a56d 基线 | 4,210 nodes | 新增结构领域和前端组件已纳入索引 |
| 关系/流程 | 5,473 relationships / 115 flows | 6,274 edges / 147 flows | 新增组件调用与 API 流程,未改变既有核心数据源流程 |
| 提交影响 | 无本功能代码 | 10 文件、47 符号、1 条刷新流程 | 中风险,测试已覆盖 |
| 生产边界 | 无数据库/消息改动 | 仍无数据库、Redis、RocketMQ、部署改动 | 保持边界 |
## 残余风险
GitNexus 只描述静态关系,不能证明 AKTools 交易时段新鲜度;该项由数据源探针和后续生产观测负责。
@@ -0,0 +1,13 @@
# GitNexus 提交前变更检测
```text
命令:npx gitnexus detect-changes --scope staged --repo a-share-analysis
结果:10 files, 47 symbols
Affected processes: 1
Risk level: medium
流程:Refresh → LoadModule(新增 refreshSectorRankings/refreshCapitalFlow)
```
## 结论
变更集中在市场概览页面、composable、结构面板和一个控制器注入修复;没有数据库、消息、缓存、部署或生产配置变更。中风险来自刷新执行流扩展,已由 composable 单测、服务端测试和 Playwright 覆盖。
@@ -0,0 +1,36 @@
# GitNexus 开发前影响计划
- 功能编号:FEAT-MO-005
- 总体风险:中。新增结构边界本身独立,但共享响应 DTO 有 24 个上游影响项,必须保持旧接口字段和 `source=tdx` 行为兼容。
## 直接与间接影响
### `MarketOverviewMetaDto`(MEDIUM)
- 直接影响 12 项:现有 summary、indices、breadth、sectors、funds 响应 DTO,TDX mapper、详情 DTO/mapper、controller 和 service。
- 间接影响 12 项:概览和详情 e2e、OpenAPI 导出、模块注册及相关测试。
- 决策:新增数据源联合类型时只扩展 `source` 的允许值,不改变既有 TDX 响应结构;所有旧测试必须继续通过。
### `MarketOverviewService`(MEDIUM)
- 直接影响 7 项:概览 e2e、服务测试、OpenAPI 导出、模块/controller 和详情服务引用。
- 间接影响 6 项:API 入口和进程冒烟测试。
- 决策:新结构服务独立注入,不重写现有 `MarketOverviewService` 的业务流程;仅在模块注册层增加 provider/controller。
### `createAktoolsClient`(LOW)
- 直接影响 1 项:现有 AKTools client 测试。
- 决策:不扩展短线 client 的固定端点,新增 `AktoolsStructureClient`,避免行业/资金接口与短线情绪接口耦合。
GitNexus `query("市场概览 板块排行 资金流入流出 成交额趋势 TDX AKTools")` 定位到现有概览流程、TDX mapper 和 AKTools 模块;本任务后续只新增 structure 领域文件,不改变已验证的短线流程。
## 跨模块同步修改
| 范围 | 判断 | 说明 |
| --- | --- | --- |
| HTTP/OpenAPI | 需要 | Tasks 3–5 新增三个独立只读路由,Task 6 重新生成契约 |
| Web API/前端 | 需要 | Task 6 添加 Axios API 和 composable 状态,Task 7 添加面板 |
| 消息契约、数据库、Redis、Scheduler、Worker | 不需要 | 本切片只读请求,无持久化、缓存和异步任务 |
| API 模块注册 | 需要 | `market-overview.module.ts` 增加结构 controller/provider |
| 配置部署 | 不需要 | 复用已存在的本机 TDX/AKTools 配置,不新增凭据或地址 |
| 测试与文档 | 需要 | 新增 mapper/service/API/UI/E2E 测试和完整功能档案 |
@@ -0,0 +1,22 @@
# FEAT-MO-005 实施计划
## 目标
在综合概览保留现有指数、市场宽度、板块概览、量能和短线情绪的基础上,增加行业/题材/板块排行、资金流向和全天成交额趋势,并让每个模块独立处理 `fresh`、`delayed`、`partial`、`unavailable`。
## 已执行步骤
1. 完成 TDX/AKTools 能力探针和脱敏记录。
2. 建立市场结构领域类型、映射和 AKTools 固定端点客户端。
3. 增加三个独立 Nest API 路由并生成 OpenAPI 契约。
4. 接入 Web Axios API 分域和 composable 独立状态。
5. 实现三个 Vue 面板、分类切换、轻量 SVG 趋势图和响应式样式。
6. 完成服务端注入回归、组件测试、全量构建、E2E 和视觉检查。
## 约束与回退
不新增数据库、Redis、RocketMQ 或异步任务;不保存原始行情载荷;AKTools 未启用时页面展示不可用状态。若上游字段口径不明确,保持 `null`/`unavailable`,禁止从涨跌或成交额推导资金流。
## 安全策略与性能目标
复用既有服务端来源白名单、Axios 边界和脱敏错误。前端仅消费 Nest API,不接触 TDX/AKTools 地址或凭据。列表请求限制为 Top 100,排序和 SVG 点计算为线性复杂度,组件不引入新运行时依赖。剩余风险是免费上游在交易时段的稳定性和延迟尚未完成生产级验证。
@@ -0,0 +1,10 @@
featureId: FEAT-MO-005
slug: market-structure-turnover
name: 市场结构与成交额趋势
status: in_development
release: unreleased
scope: local_development_probe
createdAt: 2026-08-25
sources:
tdx: local_loopback_primary
aktools: local_loopback_supplement
@@ -0,0 +1,16 @@
# FEAT-MO-005 产品需求
## 目标
为市场概览补充行业、题材、板块强弱排行,明确口径的资金流向,以及全市场成交额趋势;每个模块独立显示来源、交易日期与可用状态。
## 用户价值与验收
- 用户可在一个页面比较行业、题材、板块的涨跌幅和成交额,缺失字段不以零填充。
- 资金流向只展示上游明确提供的净流入字段及其定义,禁止由涨跌或成交额推导。
- 成交额统一为亿元,盘中新鲜度和收盘后最后记录分别表达。
- 上游失败时保留已验证数据或显示 `partial`/`unavailable`,不提供离线样本。
## 范围边界
本功能只读取本机 TDX 与 AKTools 桥接服务;不连接生产环境、不写数据库、不调用交易、账户或自选接口。上游授权、稳定性和再分发权未验证前,不作为生产数据源承诺。
@@ -0,0 +1,18 @@
# FEAT-MO-005 测试结果
## 自动化结果
- `pnpm lint`:通过,0 error;仓库既有 Vue 风格 warning 未扩大为错误。
- `pnpm format:check`:通过。
- `pnpm typecheck`:Web/Server 均通过。
- `pnpm test`:治理、契约、边界、部署、Web、Server 全部通过;Web 39 tests,Server 110 tests。
- `pnpm build`:Web Vite 和 Server API/Scheduler/Worker 构建通过。
- `pnpm verify:e2e`:4/4 通过,桌面 1440px 和移动 390px 均无横向溢出。
## 安全与性能证据
未新增凭据、原始载荷、生产地址或持久化写入。排行限制 1-100,前端仅通过 Axios 封装访问 API;SVG 趋势点计算和本地排序均为 O(n),n 为已限制返回项/点数。
## 失败处置
首次 E2E 发现三个结构路由返回 400,定位为控制器缺少显式服务注入;修复后服务端 21 文件/110 测试与 E2E 4 项全部通过。AKTools 在测试配置中禁用,结构和趋势模块按约定显示 unavailable,未伪造样本数据。
@@ -0,0 +1,18 @@
# FEAT-MO-005 视觉 QA
## 视口
- 桌面:1440 × 900,完整页面截图检查通过。
- 移动:390 × 900,完整页面截图检查通过。
## 检查项
- 新增排行、资金流、成交额趋势和短线情绪模块按独立网格排列,无卡片嵌套。
- 移动端网格变为单列,排行行的辅助字段隐藏,文字不溢出。
- 不可用/延迟/部分数据状态使用既有警告样式,未出现空白或 `0.00` 伪造资金值。
- 页面 `scrollWidth === clientWidth`,桌面和移动均无横向滚动。
- 成交额趋势在真实 unavailable 状态显示原因;有点位时 SVG polyline 具备非空点集并保持固定高度。
## 残余风险
当前 AKTools 未启用,无法进行真实排行数据的视觉密度检查;后续上游可用时需复核长行业名称和 100 条列表边界。
@@ -0,0 +1,16 @@
# FEAT-MO-005 发布说明
## 本次内容
- 综合概览新增行业、题材、板块强弱排行。
- 新增行业/题材/板块资金流向面板,保留上游字段口径和缺失状态。
- 新增全市场全天成交额趋势面板,单位为亿元,支持轻量 SVG 趋势线。
- 分类切换触发独立 API 刷新,既有概览模块保持不变。
## 数据与限制
当前 AKTools 未启用或不可用时,相关模块显示 `unavailable`;未验证的板块端点不会伪造数据。该功能未引入数据库、Redis、RocketMQ、AI、用户体系或生产部署变更。
## 发布状态
代码已提交并推送分支 `codex/feat-mo-005-market-structure-turnover`,待合并和后续真实交易时段数据源验证后再发布。
@@ -0,0 +1,17 @@
# FEAT-MO-005 工程需求
## 数据与状态
- 服务端固定白名单访问上游,浏览器只访问 Nest API。
- 状态只能为 `fresh`、`delayed`、`partial`、`unavailable`;收盘后最后记录不等于盘中新鲜。
- `turnoverAmount`、`cumulativeAmount` 统一为亿元;`netInflow` 仅使用上游明示的净额字段。
- 每条输出保留 `source`、`tradeDate`、`asOf` 与适用的字段定义;未知字段返回 `null` 或省略。
## 同步影响判定
| 项目 | 本任务结论 | 边界 |
| --- | --- | --- |
| HTTP/OpenAPI、Web API | 不修改 | 本任务仅能力探针与档案。 |
| 消息、数据库、Redis、Scheduler、Worker、部署 | 不修改 | 只读本机调用,无持久化和后台任务。 |
| API 数据源适配器 | 后续任务需要 | 仅接入本报告标为可用或部分可用且有明确降级的接口。 |
| 测试与文档 | 已修改 | Node 探针测试和功能档案共同记录证据。 |
@@ -0,0 +1,26 @@
# FEAT-MO-005 代码 Review
## 安全与残余风险
PASS。前端没有新增直连上游、凭据或原始响应暴露;组件只消费生成 DTO,资金缺失保持 `null` 语义。控制器显式注入服务,避免运行时未定义调用。残余风险为 AKTools 免费服务的授权、稳定性和交易时段延迟未经生产验证。
## 性能与资源影响
PASS。排行请求仍受服务端 Top 100 白名单限制,前端排序是有限数组上的 O(n),SVG 趋势点是 O(n),没有新增运行时依赖或持久化资源。全量构建和 E2E 已验证页面负载边界。
## GitNexus 与同步修改
PASS。GitNexus 检测为中风险,1 条刷新流程受影响;已同步更新 composable、页面、组件、样式和测试。HTTP/OpenAPI、数据库、Redis、RocketMQ、Scheduler、Worker、部署边界均按影响计划复核,只有已有结构 API 和 Web 消费者需要变更。
## 发现与处置
| 严重程度 | 发现 | 处置 |
| --- | --- | --- |
| Important | 新控制器未显式注入服务导致 E2E 400 | 增加 `@Inject(MarketStructureService)`,回归通过 |
| Minor | 上游资金流字段可能缺失 | 显示 `未提供`,禁止推导零值 |
## 结论
代码符合当前设计和安全边界,未发现阻断发布的问题。
最终结论:passed
@@ -0,0 +1,22 @@
# FEAT-MO-005 开发规范 Review
## 安全、性能与架构检查
PASS。实现沿用 Nest 业务模块、Axios request 封装和独立 API 文件夹;没有绕过前端请求边界,没有引入 Docker、数据库或消息依赖。中文注释覆盖数据口径和异常降级;响应列表、SVG 和分类请求均有明确边界。
## GitNexus 与开发总结检查
PASS。开发前影响计划、提交前检测、开发后索引和前后对比已归档;`development-summary.md` 明确记录原始需求、范围变化、完成项、缺陷、延期项、提交和验证证据。用户原有未跟踪 CSV 未被修改或纳入提交。
## Review 证据
- 中文 Conventional Commit:`feat(FEAT-MO-005): 展示市场结构与成交额趋势`。
- 当前分支已推送项目 Gitea,未修改全局 Git 配置。
- Schema 基线和开发库约束未被本功能绕过;本轮无数据库对象。
- 根级验证、服务端/前端测试、构建和桌面/移动视觉检查均有记录。
## 结论
未发现违反项目代码开发规范、流程规范或文档追溯要求的问题。
最终结论:passed
@@ -0,0 +1,5 @@
# FEAT-MO-005 回退说明
本任务仅新增本地只读探针与文档,无运行时服务、数据库迁移、消息、缓存或部署变更。若探针产生误导性结果,删除后续对该映射的引用并恢复到本提交前的功能档案;不得通过重试把失败源标记为可用。
后续 API 一旦错误标记盘中新鲜、泄漏上游信息或使用未验证净流入口径,应立即关闭对应固定白名单端点,返回 `unavailable`,并保留最后已验证快照的实际日期。
@@ -0,0 +1,18 @@
# FEAT-MO-005 测试计划
## 自动化测试
- `node --test tools/probes/market-structure-capability.test.mjs`:验证 CLI 明示固定本机和只读边界;验证 TDX 列式 `Date` 与 `Amount` 等长、分钟时间戳的对齐判定。
- 手工只读探测:收盘后连续执行两次,记录状态、耗时、行数、字段、首末时间与错误类别;交易时段另执行一次。
## 安全验证
验证 `--base-url` 拒绝非回环地址和错误端口;响应体超过 2 MiB、无效 JSON、HTTP 500 与超时仅归类,不输出载荷、请求体或凭据。当前测试覆盖 CLI 边界和列式字段;上游异常路径由本机实测覆盖,后续适配器需补全固定样本契约测试。
## 性能验证
记录每个固定调用耗时,10 秒超时可重复验证。该探针不产生数据库、缓存、消息或前端渲染负载;交易时段连续观测尚未完成,不能证明新鲜度或 P95 容量。
## 通过条件与残余风险
只有映射表已记录、探针不泄漏原始内容、自动化测试通过且三次时段观测齐备,才可将具体源升级为生产候选。当前缺少交易时段观测,源映射维持保守状态。
@@ -2,6 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export type MarketOverviewStatus = 'fresh' | 'delayed' | 'partial' | 'unavailable';
export type QuoteDirection = 'up' | 'down' | 'flat';
export type MarketOverviewSource = 'tdx' | 'aktools' | 'mixed';
export class MarketOverviewMetaDto {
@ApiProperty({ type: String })
@@ -10,8 +11,8 @@ export class MarketOverviewMetaDto {
@ApiProperty({ type: String, format: 'date-time' })
asOf!: string;
@ApiProperty({ type: String, enum: ['tdx'] })
source!: 'tdx';
@ApiProperty({ type: String, enum: ['tdx', 'aktools', 'mixed'] })
source!: MarketOverviewSource;
@ApiProperty({ enum: ['fresh', 'delayed', 'partial', 'unavailable'] })
status!: MarketOverviewStatus;
@@ -9,6 +9,12 @@ import { createAktoolsClient } from './aktools/aktools.client';
import { createTdxClient } from './tdx/tdx.client';
import { MarketDetailController } from './detail/market-detail.controller';
import { MarketDetailService } from './detail/market-detail.service';
import { createAktoolsStructureClient } from './structure/aktools-structure.client';
import { MarketStructureController } from './structure/market-structure.controller';
import {
MARKET_STRUCTURE_SOURCE_PORT,
MarketStructureService,
} from './structure/market-structure.service';
@Module({})
export class MarketOverviewModule {
@@ -16,13 +22,23 @@ export class MarketOverviewModule {
static register(config: ApiRuntimeConfig): DynamicModule {
return {
module: MarketOverviewModule,
controllers: [MarketOverviewController, MarketDetailController, ShortTermMarketController],
controllers: [
MarketOverviewController,
MarketDetailController,
ShortTermMarketController,
MarketStructureController,
],
providers: [
MarketOverviewService,
MarketDetailService,
MarketStructureService,
{ provide: TDX_CLIENT_PORT, useValue: createTdxClient(config.tdx) },
ShortTermMarketService,
{ provide: AKTOOLS_CLIENT_PORT, useValue: createAktoolsClient(config.aktools) },
{
provide: MARKET_STRUCTURE_SOURCE_PORT,
useValue: createAktoolsStructureClient(config.aktools),
},
],
};
}
@@ -0,0 +1,45 @@
import { describe, expect, it, vi } from 'vitest';
import { createAktoolsStructureClient } from './aktools-structure.client';
describe('AKTools 市场结构客户端', () => {
it('只调用固定的行业接口并解析数组', async () => {
const fetchImpl = vi
.fn<typeof fetch>()
.mockResolvedValue(new Response(JSON.stringify([{ 名称: '半导体' }]), { status: 200 }));
const client = createAktoolsStructureClient({
enabled: true,
baseUrl: 'http://127.0.0.1:8080',
timeoutMs: 1000,
fetchImpl,
});
await expect(client.getSectorRows('industry')).resolves.toEqual([{ 名称: '半导体' }]);
expect(fetchImpl).toHaveBeenCalledWith(
'http://127.0.0.1:8080/api/public/stock_board_industry_spot_em',
expect.objectContaining({ method: 'GET' }),
);
});
it('不为未验证的 board 分类拼接任意接口', async () => {
const client = createAktoolsStructureClient({
enabled: true,
baseUrl: 'http://127.0.0.1:8080',
timeoutMs: 1000,
fetchImpl: vi.fn(),
});
await expect(client.getSectorRows('board')).rejects.toMatchObject({ kind: 'unavailable' });
});
it('服务关闭时不发起请求', async () => {
const fetchImpl = vi.fn<typeof fetch>();
const client = createAktoolsStructureClient({
enabled: false,
baseUrl: 'http://127.0.0.1:8080',
timeoutMs: 1000,
fetchImpl,
});
await expect(client.getCapitalFlowRows('industry')).rejects.toMatchObject({ kind: 'disabled' });
expect(fetchImpl).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,100 @@
import type { AktoolsRuntimeConfig } from '../../../../../libs/config/src/runtime-config';
import { AktoolsClientError } from '../aktools/aktools.client';
import type {
MarketStructureSourceClient,
StructureCategory,
StructureRawRow,
} from './market-structure.types';
export interface AktoolsStructureClientOptions extends AktoolsRuntimeConfig {
fetchImpl?: typeof fetch;
maxResponseBytes?: number;
}
const DEFAULT_MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
const ENDPOINTS = Object.freeze({
industry: 'stock_board_industry_spot_em',
concept: 'stock_board_concept_spot_em',
capitalFlow: 'stock_sector_fund_flow_rank',
marketFundFlow: 'stock_market_fund_flow',
});
function isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === 'AbortError';
}
function categoryEndpoint(category: StructureCategory): string {
if (category === 'industry') return ENDPOINTS.industry;
if (category === 'concept') return ENDPOINTS.concept;
throw new AktoolsClientError('unavailable', '板块分类数据源未验证');
}
function categoryFlowType(category: StructureCategory): string {
if (category === 'industry') return '行业资金流';
if (category === 'concept') return '概念资金流';
throw new AktoolsClientError('unavailable', '板块资金流数据源未验证');
}
export function createAktoolsStructureClient({
enabled,
baseUrl,
timeoutMs,
fetchImpl = fetch,
maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES,
}: AktoolsStructureClientOptions): MarketStructureSourceClient {
const endpoint = `${baseUrl.replace(/\/$/, '')}/api/public/`;
async function request(
path: string,
params: Readonly<Record<string, string>> = {},
): Promise<StructureRawRow[]> {
if (!enabled) throw new AktoolsClientError('disabled', 'AKTools 未启用');
const search = new URLSearchParams(params);
const url = `${endpoint}${path}${search.toString() ? `?${search.toString()}` : ''}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetchImpl(url, {
method: 'GET',
headers: { accept: 'application/json' },
signal: controller.signal,
});
const contentLength = response.headers.get('content-length');
if (contentLength && Number(contentLength) > maxResponseBytes) {
throw new AktoolsClientError('protocol', 'AKTools 响应体超过安全上限');
}
const text = await response.text();
if (Buffer.byteLength(text, 'utf8') > maxResponseBytes) {
throw new AktoolsClientError('protocol', 'AKTools 响应体超过安全上限');
}
if (!response.ok) throw new AktoolsClientError('unavailable', 'AKTools 服务不可用');
let value: unknown;
try {
value = JSON.parse(text);
} catch {
throw new AktoolsClientError('protocol', 'AKTools 返回格式错误');
}
if (!Array.isArray(value) || value.some((item) => !item || typeof item !== 'object')) {
throw new AktoolsClientError('protocol', 'AKTools 返回数据结构错误');
}
return value as StructureRawRow[];
} catch (error) {
if (error instanceof AktoolsClientError) throw error;
if (isAbortError(error)) throw new AktoolsClientError('timeout', 'AKTools 请求超时');
throw new AktoolsClientError('network', 'AKTools 网络请求失败');
} finally {
clearTimeout(timeout);
}
}
return {
getSectorRows: async (category) => request(categoryEndpoint(category)),
getCapitalFlowRows: async (category) =>
request(ENDPOINTS.capitalFlow, {
indicator: '今日',
sector_type: categoryFlowType(category),
}),
getMarketFundFlowRows: () => request(ENDPOINTS.marketFundFlow),
};
}
@@ -0,0 +1,69 @@
import { BadRequestException, Controller, Get, Inject, Query } from '@nestjs/common';
import { ApiOkResponse, ApiQuery, ApiTags } from '@nestjs/swagger';
import {
CapitalFlowResponseDto,
SectorRankingsResponseDto,
TurnoverTrendResponseDto,
} from './market-structure.dto';
import {
MarketStructureService,
parseCapitalFlowQuery,
parseSectorRankingQuery,
parseTurnoverTrendQuery,
} from './market-structure.service';
@ApiTags('market-overview-structure')
@Controller('market-overview')
export class MarketStructureController {
constructor(@Inject(MarketStructureService) private readonly service: MarketStructureService) {}
@Get('sector-rankings')
@ApiQuery({ name: 'category', required: false, enum: ['industry', 'concept', 'board'] })
@ApiQuery({ name: 'sort', required: false, enum: ['rise', 'fall', 'turnover'] })
@ApiQuery({ name: 'limit', required: false, type: Number, minimum: 1, maximum: 100 })
@ApiOkResponse({ type: SectorRankingsResponseDto })
getSectorRankings(
@Query('category') category?: string,
@Query('sort') sort?: string,
@Query('limit') limit?: string,
): Promise<SectorRankingsResponseDto> {
try {
return this.service.getSectorRankings(parseSectorRankingQuery(category, sort, limit));
} catch (error) {
throw new BadRequestException(error instanceof Error ? error.message : '排行参数无效');
}
}
@Get('capital-flow')
@ApiQuery({ name: 'category', required: false, enum: ['industry', 'concept', 'board'] })
@ApiQuery({ name: 'sort', required: false, enum: ['inflow', 'outflow'] })
@ApiQuery({ name: 'limit', required: false, type: Number, minimum: 1, maximum: 100 })
@ApiOkResponse({ type: CapitalFlowResponseDto })
getCapitalFlow(
@Query('category') category?: string,
@Query('sort') sort?: string,
@Query('limit') limit?: string,
): Promise<CapitalFlowResponseDto> {
try {
return this.service.getCapitalFlow(parseCapitalFlowQuery(category, sort, limit));
} catch (error) {
throw new BadRequestException(error instanceof Error ? error.message : '资金流参数无效');
}
}
@Get('turnover-trend')
@ApiQuery({ name: 'interval', required: false, enum: ['5', '15'] })
@ApiQuery({ name: 'tradeDate', required: false, type: String, pattern: '^\\d{8}$' })
@ApiOkResponse({ type: TurnoverTrendResponseDto })
getTurnoverTrend(
@Query('interval') interval?: string,
@Query('tradeDate') tradeDate?: string,
): Promise<TurnoverTrendResponseDto> {
try {
return this.service.getTurnoverTrend(parseTurnoverTrendQuery(interval, tradeDate));
} catch (error) {
throw new BadRequestException(error instanceof Error ? error.message : '成交额趋势参数无效');
}
}
}
@@ -0,0 +1,124 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { MarketOverviewMetaDto } from '../market-overview.dto';
import type { StructureCategory, StructureSort } from './market-structure.types';
export class SectorRankingItemDto {
@ApiProperty({ enum: ['industry', 'concept', 'board'] })
category!: StructureCategory;
@ApiProperty({ type: String })
code!: string;
@ApiProperty({ type: String })
name!: string;
@ApiPropertyOptional({ type: Number, nullable: true })
changePercent!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true, description: '亿元' })
turnoverAmount!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true })
advancingCount!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true })
decliningCount!: number | null;
@ApiPropertyOptional({ type: String, nullable: true })
leader!: string | null;
@ApiPropertyOptional({ type: String, nullable: true, example: '20260825' })
tradeDate!: string | null;
}
export class SectorRankingsDataDto {
@ApiProperty({ type: [SectorRankingItemDto] })
items!: SectorRankingItemDto[];
@ApiProperty({ enum: ['rise', 'fall', 'turnover'] })
sort!: Extract<StructureSort, 'rise' | 'fall' | 'turnover'>;
@ApiProperty({ enum: ['industry', 'concept', 'board'] })
category!: StructureCategory;
}
export class SectorRankingsResponseDto extends MarketOverviewMetaDto {
@ApiProperty({ type: () => SectorRankingsDataDto, nullable: true })
data!: SectorRankingsDataDto | null;
}
export class CapitalFlowItemDto {
@ApiProperty({ enum: ['industry', 'concept', 'board'] })
category!: StructureCategory;
@ApiPropertyOptional({ type: String, nullable: true })
code!: string | null;
@ApiProperty({ type: String })
name!: string;
@ApiPropertyOptional({ type: Number, nullable: true, description: '亿元' })
netInflow!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true, description: '亿元' })
netOutflow!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true, description: '亿元' })
turnoverAmount!: number | null;
@ApiPropertyOptional({ type: String, nullable: true })
tradeDate!: string | null;
@ApiProperty({ type: String })
flowDefinition!: string;
}
export class CapitalFlowDataDto {
@ApiProperty({ type: [CapitalFlowItemDto] })
items!: CapitalFlowItemDto[];
@ApiProperty({ enum: ['inflow', 'outflow'] })
sort!: Extract<StructureSort, 'inflow' | 'outflow'>;
@ApiProperty({ enum: ['industry', 'concept', 'board'] })
category!: StructureCategory;
}
export class CapitalFlowResponseDto extends MarketOverviewMetaDto {
@ApiProperty({ type: () => CapitalFlowDataDto, nullable: true })
data!: CapitalFlowDataDto | null;
}
export class TurnoverPointDto {
@ApiProperty({ type: String, format: 'date-time' })
time!: string;
@ApiProperty({ type: Number, description: '该时间区间成交额,单位亿元' })
amount!: number;
@ApiProperty({ type: Number, description: '当日累计成交额,单位亿元' })
cumulativeAmount!: number;
}
export class TurnoverTrendDataDto {
@ApiProperty({ type: [TurnoverPointDto] })
points!: TurnoverPointDto[];
@ApiProperty({ enum: ['5', '15'] })
granularity!: '5' | '15';
@ApiProperty({ type: String, example: '亿元' })
unit!: '亿元';
@ApiProperty({ type: Number, minimum: 1, maximum: 3 })
marketCount!: number;
@ApiProperty({ type: Number })
invalidPointCount!: number;
}
export class TurnoverTrendResponseDto extends MarketOverviewMetaDto {
@ApiProperty({ type: () => TurnoverTrendDataDto, nullable: true })
data!: TurnoverTrendDataDto | null;
}
@@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest';
import { mapCapitalFlow, mapSectorRanking, mapTurnoverPoints } from './market-structure.mapper';
describe('市场结构数据映射', () => {
it('映射行业排行并保留涨跌幅与成交额', () => {
expect(
mapSectorRanking(
{
代码: 'BK0001',
名称: '半导体',
涨跌幅: '2.5',
成交额: '123456789',
上涨家数: '20',
下跌家数: '4',
领涨股: '测试股份',
},
'industry',
'20260825',
),
).toEqual({
category: 'industry',
code: 'BK0001',
name: '半导体',
changePercent: 2.5,
turnoverAmount: 1.23,
advancingCount: 20,
decliningCount: 4,
leader: '测试股份',
tradeDate: '20260825',
});
});
it('拒绝未知分类和缺少代码名称的排行记录', () => {
expect(mapSectorRanking({ code: 'BK0001', name: '测试' }, 'board', '20260825')).toMatchObject({
category: 'board',
code: 'BK0001',
});
expect(mapSectorRanking({ 名称: '无代码' }, 'industry', '20260825')).toBeNull();
});
it('只使用上游明确的净流入字段,不用成交额推导资金', () => {
expect(
mapCapitalFlow(
{
名称: '半导体',
'今日主力净流入-净额': '100000000',
成交额: '500000000',
},
'industry',
'20260825',
),
).toMatchObject({
name: '半导体',
netInflow: 1,
netOutflow: null,
turnoverAmount: 5,
flowDefinition: '今日主力净流入-净额',
});
expect(mapCapitalFlow({ 名称: '未知', 成交额: '100' }, 'industry', '20260825')).toMatchObject({
netInflow: null,
netOutflow: null,
flowDefinition: 'unavailable',
});
});
it('拒绝倒序、重复和负数成交额点并计算累计值', () => {
expect(
mapTurnoverPoints([
{ 时间: '2026-08-25 09:30:00', 成交额: '100000000' },
{ 时间: '2026-08-25 09:35:00', 成交额: '200000000' },
{ 时间: '2026-08-25 09:35:00', 成交额: '300000000' },
{ 时间: '2026-08-25 09:34:00', 成交额: '400000000' },
{ 时间: '2026-08-25 09:40:00', 成交额: '-100000000' },
]),
).toEqual({
points: [
{ time: '2026-08-25 09:30:00', amount: 1, cumulativeAmount: 1 },
{ time: '2026-08-25 09:35:00', amount: 2, cumulativeAmount: 3 },
],
invalidCount: 3,
});
});
});
@@ -0,0 +1,120 @@
import type {
CapitalFlowRecord,
SectorRankingRecord,
StructureCategory,
StructureRawRow,
TurnoverMappingResult,
TurnoverPoint,
} from './market-structure.types';
function toNumber(value: unknown): number | null {
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
if (typeof value !== 'string' || !value.trim()) return null;
const parsed = Number(value.replaceAll(',', ''));
return Number.isFinite(parsed) ? parsed : null;
}
function toNonNegativeNumber(value: unknown): number | null {
const parsed = toNumber(value);
return parsed !== null && parsed >= 0 ? parsed : null;
}
function toText(value: unknown): string | null {
if (typeof value !== 'string') return null;
const text = value.trim();
return text ? text : null;
}
function firstValue(row: StructureRawRow, keys: readonly string[]): unknown {
for (const key of keys) {
if (row[key] !== undefined) return row[key];
}
return undefined;
}
function amountInYi(value: unknown): number | null {
const parsed = toNonNegativeNumber(value);
return parsed === null ? null : Math.round((parsed / 100_000_000) * 100) / 100;
}
function dateValue(value: unknown): string | null {
const text = toText(value);
return text && /^\d{8}$/.test(text) ? text : text;
}
export function mapSectorRanking(
row: StructureRawRow,
category: StructureCategory,
tradeDate: string | null,
): SectorRankingRecord | null {
const code = toText(firstValue(row, ['代码', '板块代码', 'code', 'f12']));
const name = toText(firstValue(row, ['名称', '板块名称', 'name', 'f14']));
if (!code || !name) return null;
return {
category,
code,
name,
changePercent: toNumber(firstValue(row, ['涨跌幅', '今日涨跌幅', 'changePercent', 'f3'])),
turnoverAmount: amountInYi(firstValue(row, ['成交额', 'turnoverAmount', 'f48'])),
advancingCount: toNonNegativeNumber(firstValue(row, ['上涨家数', 'advancingCount'])),
decliningCount: toNonNegativeNumber(firstValue(row, ['下跌家数', 'decliningCount'])),
leader: toText(firstValue(row, ['领涨股', '主力净流入最大股', 'leader'])),
tradeDate: dateValue(tradeDate),
};
}
export function mapCapitalFlow(
row: StructureRawRow,
category: StructureCategory,
tradeDate: string | null,
): CapitalFlowRecord {
const definitionKeys = ['今日主力净流入-净额', '主力净流入-净额', 'netInflow'] as const;
const netInflowRaw = firstValue(row, definitionKeys);
const netInflow = toNumber(netInflowRaw);
const netOutflow = toNumber(firstValue(row, ['netOutflow', '净流出', '净流出额']));
const flowDefinition =
netInflowRaw !== undefined
? (definitionKeys.find((key) => row[key] !== undefined) ?? 'unknown')
: 'unavailable';
return {
category,
code: toText(firstValue(row, ['代码', '板块代码', 'code', 'f12'])),
name: toText(firstValue(row, ['名称', '板块名称', 'name', 'f14'])) ?? '未知板块',
netInflow: netInflow === null ? null : Math.round((netInflow / 100_000_000) * 100) / 100,
netOutflow: netOutflow === null ? null : Math.round((netOutflow / 100_000_000) * 100) / 100,
turnoverAmount: amountInYi(firstValue(row, ['成交额', 'turnoverAmount', 'f48'])),
tradeDate: dateValue(tradeDate),
flowDefinition,
};
}
function parseTime(row: StructureRawRow): string | null {
return toText(firstValue(row, ['时间', '日期时间', '时间戳', 'time', 'datetime', 'Date']));
}
export function mapTurnoverPoints(rows: readonly StructureRawRow[]): TurnoverMappingResult {
const points: TurnoverPoint[] = [];
const seen = new Set<string>();
let invalidCount = 0;
for (const row of rows) {
const time = parseTime(row);
const amount = amountInYi(firstValue(row, ['成交额', 'amount', 'Amount']));
if (
!time ||
amount === null ||
seen.has(time) ||
(points.length > 0 && time <= points.at(-1)!.time)
) {
invalidCount += 1;
continue;
}
seen.add(time);
const previous = points.at(-1)?.cumulativeAmount ?? 0;
points.push({ time, amount, cumulativeAmount: Math.round((previous + amount) * 100) / 100 });
}
return { points, invalidCount };
}
@@ -0,0 +1,156 @@
import { describe, expect, it } from 'vitest';
import {
MarketStructureService,
parseCapitalFlowQuery,
parseSectorRankingQuery,
parseTurnoverTrendQuery,
} from './market-structure.service';
import type { MarketStructureSourceClient } from './market-structure.types';
import type { TdxClient } from '../tdx/tdx.client';
const source: MarketStructureSourceClient = {
getSectorRows: async () => [
{ 代码: 'BK2', 名称: '弱势', 涨跌幅: '-2', 成交额: '200000000', 日期: '20260825' },
{ 代码: 'BK1', 名称: '强势', 涨跌幅: '5', 成交额: '100000000', 日期: '20260825' },
],
getCapitalFlowRows: async () => [
{ 代码: 'BK1', 名称: '强势', '今日主力净流入-净额': '200000000', 日期: '20260825' },
{ 代码: 'BK2', 名称: '弱势', '今日主力净流入-净额': '-100000000', 日期: '20260825' },
],
getMarketFundFlowRows: async () => [],
};
describe('行业题材排行服务', () => {
it('校验分类、排序和 1-100 数量范围', () => {
expect(parseSectorRankingQuery('concept', 'fall', '10')).toEqual({
category: 'concept',
sort: 'fall',
limit: 10,
});
expect(() => parseSectorRankingQuery('stock', 'rise', '10')).toThrow('分类参数无效');
expect(() => parseSectorRankingQuery('industry', 'unknown', '10')).toThrow('排序参数无效');
expect(() => parseSectorRankingQuery('industry', 'rise', '0')).toThrow('数量参数无效');
expect(() => parseSectorRankingQuery('industry', 'rise', '101')).toThrow('数量参数无效');
});
it('按涨幅排序并截取指定数量', async () => {
const service = new MarketStructureService(source);
const response = await service.getSectorRankings({
category: 'industry',
sort: 'rise',
limit: 1,
});
expect(response.status).toBe('fresh');
expect(response.data?.items).toHaveLength(1);
expect(response.data?.items[0]).toMatchObject({ code: 'BK1', changePercent: 5 });
});
it('上游失败时返回不可用且不泄露异常内容', async () => {
const failing: MarketStructureSourceClient = {
...source,
getSectorRows: async () => {
throw new Error('secret upstream URL and payload');
},
};
const service = new MarketStructureService(failing);
const response = await service.getSectorRankings({
category: 'industry',
sort: 'rise',
limit: 10,
});
expect(response).toMatchObject({ status: 'unavailable', data: null });
expect(response.warnings).toEqual(['行业排行数据不可用']);
});
it('校验资金流分类、排序和数量参数', () => {
expect(parseCapitalFlowQuery('concept', 'outflow', '20')).toEqual({
category: 'concept',
sort: 'outflow',
limit: 20,
});
expect(() => parseCapitalFlowQuery('stock', 'inflow', '10')).toThrow('分类参数无效');
expect(() => parseCapitalFlowQuery('industry', 'unknown', '10')).toThrow('排序参数无效');
expect(() => parseCapitalFlowQuery('industry', 'inflow', '101')).toThrow('数量参数无效');
});
it('按净流入排序并保留资金口径', async () => {
const service = new MarketStructureService(source);
const response = await service.getCapitalFlow({
category: 'industry',
sort: 'inflow',
limit: 10,
});
expect(response.status).toBe('fresh');
expect(response.data?.items[0]).toMatchObject({
code: 'BK1',
netInflow: 2,
flowDefinition: '今日主力净流入-净额',
});
});
it('缺少上游净流入字段时标记 partial,不填充零值', async () => {
const partial: MarketStructureSourceClient = {
...source,
getCapitalFlowRows: async () => [{ 代码: 'BK1', 名称: '未知', 成交额: '100000000' }],
};
const response = await new MarketStructureService(partial).getCapitalFlow({
category: 'industry',
sort: 'inflow',
limit: 10,
});
expect(response.status).toBe('partial');
expect(response.data?.items[0]).toMatchObject({
netInflow: null,
flowDefinition: 'unavailable',
});
});
it('校验成交额趋势粒度和交易日期', () => {
expect(parseTurnoverTrendQuery('15', '20260824')).toEqual({
interval: '15',
tradeDate: '20260824',
});
expect(() => parseTurnoverTrendQuery('1', '20260824')).toThrow('粒度参数无效');
expect(() => parseTurnoverTrendQuery('5', '20260231')).toThrow('交易日期参数无效');
});
it('聚合多个市场的 TDX 分时成交额并计算累计值', async () => {
const tdxClient: TdxClient = {
request: async <TResponse>() =>
({
ErrorId: '0',
Value: {
'000001.SH': {
Date: ['2026-08-24 09:30:00', '2026-08-24 09:35:00'],
Amount: ['10000', '20000'],
},
'399001.SZ': {
Date: ['2026-08-24 09:30:00', '2026-08-24 09:35:00'],
Amount: ['30000', '40000'],
},
},
}) as TResponse,
};
const response = await new MarketStructureService(source, tdxClient).getTurnoverTrend({
interval: '5',
tradeDate: '20260824',
});
expect(response.status).toBe('partial');
expect(response.data?.points).toEqual([
{ time: '2026-08-24 09:30:00', amount: 4, cumulativeAmount: 4 },
{ time: '2026-08-24 09:35:00', amount: 6, cumulativeAmount: 10 },
]);
});
it('TDX 分时序列为空时返回不可用', async () => {
const tdxClient: TdxClient = {
request: async <TResponse>() => ({ ErrorId: '0', Value: {} }) as TResponse,
};
const response = await new MarketStructureService(source, tdxClient).getTurnoverTrend({
interval: '5',
tradeDate: '20260824',
});
expect(response).toMatchObject({ status: 'unavailable', data: null });
});
});
@@ -0,0 +1,335 @@
import { Inject, Injectable, Optional } from '@nestjs/common';
import type { MarketOverviewStatus } from '../market-overview.dto';
import { TDX_CLIENT_PORT } from '../market-overview.service';
import type { TdxClient } from '../tdx/tdx.client';
import { TURNOVER_INDEXES, type TdxKlineSeries, type TdxMarketDataResult } from '../tdx/tdx.types';
import { mapCapitalFlow, mapSectorRanking } from './market-structure.mapper';
import { mapTurnoverPoints } from './market-structure.mapper';
import type {
CapitalFlowRecord,
SectorRankingRecord,
StructureCategory,
StructureSort,
MarketStructureSourceClient,
} from './market-structure.types';
import type {
CapitalFlowResponseDto,
SectorRankingsResponseDto,
TurnoverTrendResponseDto,
} from './market-structure.dto';
export const MARKET_STRUCTURE_SOURCE_PORT = Symbol('MARKET_STRUCTURE_SOURCE_PORT');
export interface SectorRankingQuery {
category: StructureCategory;
sort: Extract<StructureSort, 'rise' | 'fall' | 'turnover'>;
limit: number;
}
export interface CapitalFlowQuery {
category: StructureCategory;
sort: Extract<StructureSort, 'inflow' | 'outflow'>;
limit: number;
}
export interface TurnoverTrendQuery {
interval: '5' | '15';
tradeDate: string;
}
const CATEGORIES = new Set<StructureCategory>(['industry', 'concept', 'board']);
const SORTS = new Set<SectorRankingQuery['sort']>(['rise', 'fall', 'turnover']);
const FLOW_SORTS = new Set<CapitalFlowQuery['sort']>(['inflow', 'outflow']);
export function parseSectorRankingQuery(
category = 'industry',
sort = 'rise',
limit = '10',
): SectorRankingQuery {
if (!CATEGORIES.has(category as StructureCategory)) throw new Error('分类参数无效');
if (!SORTS.has(sort as SectorRankingQuery['sort'])) throw new Error('排序参数无效');
if (!/^\d+$/.test(limit)) throw new Error('数量参数无效');
const parsedLimit = Number(limit);
if (!Number.isSafeInteger(parsedLimit) || parsedLimit < 1 || parsedLimit > 100) {
throw new Error('数量参数无效');
}
return {
category: category as StructureCategory,
sort: sort as SectorRankingQuery['sort'],
limit: parsedLimit,
};
}
export function parseCapitalFlowQuery(
category = 'industry',
sort = 'inflow',
limit = '10',
): CapitalFlowQuery {
if (!CATEGORIES.has(category as StructureCategory)) throw new Error('分类参数无效');
if (!FLOW_SORTS.has(sort as CapitalFlowQuery['sort'])) throw new Error('排序参数无效');
if (!/^\d+$/.test(limit)) throw new Error('数量参数无效');
const parsedLimit = Number(limit);
if (!Number.isSafeInteger(parsedLimit) || parsedLimit < 1 || parsedLimit > 100) {
throw new Error('数量参数无效');
}
return {
category: category as StructureCategory,
sort: sort as CapitalFlowQuery['sort'],
limit: parsedLimit,
};
}
function isValidDate(value: string): boolean {
if (!/^\d{8}$/.test(value)) return false;
const date = new Date(`${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}T00:00:00Z`);
return (
date.getUTCFullYear() === Number(value.slice(0, 4)) &&
date.getUTCMonth() + 1 === Number(value.slice(4, 6)) &&
date.getUTCDate() === Number(value.slice(6, 8))
);
}
export function parseTurnoverTrendQuery(
interval = '5',
tradeDate = currentShanghaiDate(),
): TurnoverTrendQuery {
if (interval !== '5' && interval !== '15') throw new Error('粒度参数无效');
if (!isValidDate(tradeDate)) throw new Error('交易日期参数无效');
return { interval, tradeDate };
}
function currentShanghaiDate(now = new Date()): string {
const values = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(now);
const parts = Object.fromEntries(values.map((item) => [item.type, item.value]));
return `${parts.year}${parts.month}${parts.day}`;
}
function snapshotMeta(
source: 'aktools',
): Pick<SectorRankingsResponseDto, 'snapshotId' | 'asOf' | 'source'> {
const asOf = new Date().toISOString();
return {
snapshotId: asOf.replace(/[-:TZ.]/g, '').slice(0, 15),
asOf,
source,
};
}
function sortItems(
items: readonly SectorRankingRecord[],
sort: SectorRankingQuery['sort'],
): SectorRankingRecord[] {
const valueFor = (item: SectorRankingRecord): number => {
if (sort === 'turnover') return item.turnoverAmount ?? Number.NEGATIVE_INFINITY;
return item.changePercent ?? Number.NEGATIVE_INFINITY;
};
const direction = sort === 'fall' ? 1 : -1;
return [...items].sort((left, right) => {
const difference = (valueFor(left) - valueFor(right)) * direction;
return difference || left.name.localeCompare(right.name, 'zh-CN');
});
}
function statusFor(items: readonly SectorRankingRecord[], rawCount: number): MarketOverviewStatus {
if (!items.length) return 'unavailable';
if (items.length < rawCount || items.some((item) => item.tradeDate === null)) return 'partial';
return 'fresh';
}
function sortFlows(
items: readonly CapitalFlowRecord[],
sort: CapitalFlowQuery['sort'],
): CapitalFlowRecord[] {
return [...items].sort((left, right) => {
const leftValue = left.netInflow ?? Number.NEGATIVE_INFINITY;
const rightValue = right.netInflow ?? Number.NEGATIVE_INFINITY;
const difference = sort === 'inflow' ? rightValue - leftValue : leftValue - rightValue;
return difference || left.name.localeCompare(right.name, 'zh-CN');
});
}
function flowStatusFor(
items: readonly CapitalFlowRecord[],
rawCount: number,
): MarketOverviewStatus {
if (!items.length) return 'unavailable';
if (
items.length < rawCount ||
items.some((item) => item.flowDefinition === 'unavailable' || item.tradeDate === null)
) {
return 'partial';
}
return 'fresh';
}
function aggregateTdxSeries(
result: TdxMarketDataResult,
tradeDate: string,
): { rows: Array<{ 时间: string; 成交额: number }>; marketCount: number } {
const amountsByTime = new Map<string, number>();
let marketCount = 0;
for (const definition of TURNOVER_INDEXES) {
const series: TdxKlineSeries | undefined = result.Value?.[definition.code];
if (!series || !Array.isArray(series.Date) || !Array.isArray(series.Amount)) continue;
if (series.Date.length === 0 || series.Date.length !== series.Amount.length) continue;
const validRows = series.Date.flatMap((time, index) => {
if (
typeof time !== 'string' ||
!time.startsWith(
`${tradeDate.slice(0, 4)}-${tradeDate.slice(4, 6)}-${tradeDate.slice(6, 8)}`,
)
)
return [];
const amount = Number(series.Amount[index]);
return Number.isFinite(amount) && amount >= 0 ? [{ time, amount: amount * 10_000 }] : [];
});
if (!validRows.length) continue;
marketCount += 1;
for (const row of validRows)
amountsByTime.set(row.time, (amountsByTime.get(row.time) ?? 0) + row.amount);
}
return {
rows: [...amountsByTime.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([时间, 成交额]) => ({ 时间, 成交额 })),
marketCount,
};
}
@Injectable()
export class MarketStructureService {
constructor(
@Inject(MARKET_STRUCTURE_SOURCE_PORT)
private readonly source: MarketStructureSourceClient,
@Optional()
@Inject(TDX_CLIENT_PORT)
private readonly tdxClient: TdxClient = {
request: async <TResponse>() => ({}) as TResponse,
},
) {}
async getSectorRankings(query: SectorRankingQuery): Promise<SectorRankingsResponseDto> {
const meta = snapshotMeta('aktools');
try {
const rows = await this.source.getSectorRows(query.category);
const items = rows.flatMap((row) => {
const mapped = mapSectorRanking(row, query.category, this.readTradeDate(row));
return mapped ? [mapped] : [];
});
const sortedAll = sortItems(items, query.sort);
const sorted = sortedAll.slice(0, query.limit);
const status = statusFor(sortedAll, rows.length);
return {
...meta,
status,
data:
status === 'unavailable'
? null
: { items: sorted, sort: query.sort, category: query.category },
warnings:
status === 'fresh'
? []
: status === 'partial'
? ['部分行业或题材排行字段不可用']
: ['行业排行数据不可用'],
};
} catch {
return {
...meta,
status: 'unavailable',
data: null,
warnings: ['行业排行数据不可用'],
};
}
}
async getCapitalFlow(query: CapitalFlowQuery): Promise<CapitalFlowResponseDto> {
const meta = snapshotMeta('aktools');
try {
const rows = await this.source.getCapitalFlowRows(query.category);
const items = rows.map((row) => mapCapitalFlow(row, query.category, this.readTradeDate(row)));
const sortedAll = sortFlows(items, query.sort);
const sorted = sortedAll.slice(0, query.limit);
const status = flowStatusFor(sortedAll, rows.length);
return {
...meta,
status,
data:
status === 'unavailable'
? null
: { items: sorted, sort: query.sort, category: query.category },
warnings:
status === 'fresh'
? []
: status === 'partial'
? ['部分资金流向字段或交易日期不可用']
: ['资金流向数据不可用'],
};
} catch {
return {
...meta,
status: 'unavailable',
data: null,
warnings: ['资金流向数据不可用'],
};
}
}
async getTurnoverTrend(query: TurnoverTrendQuery): Promise<TurnoverTrendResponseDto> {
const asOf = new Date().toISOString();
const meta = {
snapshotId: asOf.replace(/[-:TZ.]/g, '').slice(0, 15),
asOf,
source: 'tdx' as const,
};
try {
const result = await this.tdxClient.request<TdxMarketDataResult>('get_market_data', {
stock_list: TURNOVER_INDEXES.map((item) => item.code),
count: 240,
dividend_type: 'none',
period: `${query.interval}m`,
});
const aggregated = aggregateTdxSeries(result, query.tradeDate);
const mapped = mapTurnoverPoints(aggregated.rows);
if (!mapped.points.length) {
return { ...meta, status: 'unavailable', data: null, warnings: ['成交额趋势数据不可用'] };
}
const dateStatus: MarketOverviewStatus =
query.tradeDate === currentShanghaiDate() ? 'fresh' : 'delayed';
const status: MarketOverviewStatus =
aggregated.marketCount < TURNOVER_INDEXES.length || mapped.invalidCount > 0
? 'partial'
: dateStatus;
return {
...meta,
status,
data: {
points: mapped.points,
granularity: query.interval,
unit: '亿元',
marketCount: aggregated.marketCount,
invalidPointCount: mapped.invalidCount,
},
warnings:
status === 'fresh'
? []
: status === 'partial'
? ['部分市场或成交额点不可用']
: [`成交额交易日期为 ${query.tradeDate},非当前日期`],
};
} catch {
return { ...meta, status: 'unavailable', data: null, warnings: ['成交额趋势数据不可用'] };
}
}
private readTradeDate(row: Readonly<Record<string, unknown>>): string | null {
const value = row['日期'] ?? row['交易日期'] ?? row['tradeDate'];
return typeof value === 'string' && /^\d{8}$/.test(value) ? value : null;
}
}
@@ -0,0 +1,45 @@
export type StructureCategory = 'industry' | 'concept' | 'board';
export type StructureSort = 'rise' | 'fall' | 'turnover' | 'inflow' | 'outflow';
export type StructureRawRow = Readonly<Record<string, unknown>>;
export interface SectorRankingRecord {
category: StructureCategory;
code: string;
name: string;
changePercent: number | null;
turnoverAmount: number | null;
advancingCount: number | null;
decliningCount: number | null;
leader: string | null;
tradeDate: string | null;
}
export interface CapitalFlowRecord {
category: StructureCategory;
code: string | null;
name: string;
netInflow: number | null;
netOutflow: number | null;
turnoverAmount: number | null;
tradeDate: string | null;
flowDefinition: string;
}
export interface TurnoverPoint {
time: string;
amount: number;
cumulativeAmount: number;
}
export interface TurnoverMappingResult {
points: TurnoverPoint[];
invalidCount: number;
}
export interface MarketStructureSourceClient {
getSectorRows(category: StructureCategory): Promise<StructureRawRow[]>;
getCapitalFlowRows(category: StructureCategory): Promise<StructureRawRow[]>;
getMarketFundFlowRows(): Promise<StructureRawRow[]>;
}
+292 -7
View File
@@ -19,7 +19,70 @@
"type": "object"
},
"snapshotId": { "type": "string" },
"source": { "enum": ["tdx"], "type": "string" },
"source": { "enum": ["tdx", "aktools", "mixed"], "type": "string" },
"status": {
"enum": ["fresh", "delayed", "partial", "unavailable"],
"type": "string"
},
"warnings": { "items": { "type": "string" }, "type": "array" }
},
"required": ["snapshotId", "asOf", "source", "status", "warnings", "data"],
"type": "object"
},
"CapitalFlowDataDto": {
"properties": {
"category": {
"enum": ["industry", "concept", "board"],
"type": "string"
},
"items": {
"items": { "$ref": "#/components/schemas/CapitalFlowItemDto" },
"type": "array"
},
"sort": { "enum": ["inflow", "outflow"], "type": "string" }
},
"required": ["items", "sort", "category"],
"type": "object"
},
"CapitalFlowItemDto": {
"properties": {
"category": {
"enum": ["industry", "concept", "board"],
"type": "string"
},
"code": { "nullable": true, "type": "string" },
"flowDefinition": { "type": "string" },
"name": { "type": "string" },
"netInflow": {
"description": "亿元",
"nullable": true,
"type": "number"
},
"netOutflow": {
"description": "亿元",
"nullable": true,
"type": "number"
},
"tradeDate": { "nullable": true, "type": "string" },
"turnoverAmount": {
"description": "亿元",
"nullable": true,
"type": "number"
}
},
"required": ["category", "name", "flowDefinition"],
"type": "object"
},
"CapitalFlowResponseDto": {
"properties": {
"asOf": { "format": "date-time", "type": "string" },
"data": {
"allOf": [{ "$ref": "#/components/schemas/CapitalFlowDataDto" }],
"nullable": true,
"type": "object"
},
"snapshotId": { "type": "string" },
"source": { "enum": ["tdx", "aktools", "mixed"], "type": "string" },
"status": {
"enum": ["fresh", "delayed", "partial", "unavailable"],
"type": "string"
@@ -110,7 +173,7 @@
"type": "object"
},
"snapshotId": { "type": "string" },
"source": { "enum": ["tdx"], "type": "string" },
"source": { "enum": ["tdx", "aktools", "mixed"], "type": "string" },
"status": {
"enum": ["fresh", "delayed", "partial", "unavailable"],
"type": "string"
@@ -165,7 +228,7 @@
"type": "object"
},
"snapshotId": { "type": "string" },
"source": { "enum": ["tdx"], "type": "string" },
"source": { "enum": ["tdx", "aktools", "mixed"], "type": "string" },
"status": {
"enum": ["fresh", "delayed", "partial", "unavailable"],
"type": "string"
@@ -206,7 +269,7 @@
"type": "object"
},
"snapshotId": { "type": "string" },
"source": { "enum": ["tdx"], "type": "string" },
"source": { "enum": ["tdx", "aktools", "mixed"], "type": "string" },
"status": {
"enum": ["fresh", "delayed", "partial", "unavailable"],
"type": "string"
@@ -250,7 +313,7 @@
"type": "object"
},
"snapshotId": { "type": "string" },
"source": { "enum": ["tdx"], "type": "string" },
"source": { "enum": ["tdx", "aktools", "mixed"], "type": "string" },
"status": {
"enum": ["fresh", "delayed", "partial", "unavailable"],
"type": "string"
@@ -271,6 +334,66 @@
"required": ["code", "name", "changePercent", "direction", "tradeDate"],
"type": "object"
},
"SectorRankingItemDto": {
"properties": {
"advancingCount": { "nullable": true, "type": "number" },
"category": {
"enum": ["industry", "concept", "board"],
"type": "string"
},
"changePercent": { "nullable": true, "type": "number" },
"code": { "type": "string" },
"decliningCount": { "nullable": true, "type": "number" },
"leader": { "nullable": true, "type": "string" },
"name": { "type": "string" },
"tradeDate": {
"example": "20260825",
"nullable": true,
"type": "string"
},
"turnoverAmount": {
"description": "亿元",
"nullable": true,
"type": "number"
}
},
"required": ["category", "code", "name"],
"type": "object"
},
"SectorRankingsDataDto": {
"properties": {
"category": {
"enum": ["industry", "concept", "board"],
"type": "string"
},
"items": {
"items": { "$ref": "#/components/schemas/SectorRankingItemDto" },
"type": "array"
},
"sort": { "enum": ["rise", "fall", "turnover"], "type": "string" }
},
"required": ["items", "sort", "category"],
"type": "object"
},
"SectorRankingsResponseDto": {
"properties": {
"asOf": { "format": "date-time", "type": "string" },
"data": {
"allOf": [{ "$ref": "#/components/schemas/SectorRankingsDataDto" }],
"nullable": true,
"type": "object"
},
"snapshotId": { "type": "string" },
"source": { "enum": ["tdx", "aktools", "mixed"], "type": "string" },
"status": {
"enum": ["fresh", "delayed", "partial", "unavailable"],
"type": "string"
},
"warnings": { "items": { "type": "string" }, "type": "array" }
},
"required": ["snapshotId", "asOf", "source", "status", "warnings", "data"],
"type": "object"
},
"SectorsResponseDto": {
"properties": {
"asOf": { "format": "date-time", "type": "string" },
@@ -280,7 +403,7 @@
"type": "object"
},
"snapshotId": { "type": "string" },
"source": { "enum": ["tdx"], "type": "string" },
"source": { "enum": ["tdx", "aktools", "mixed"], "type": "string" },
"status": {
"enum": ["fresh", "delayed", "partial", "unavailable"],
"type": "string"
@@ -413,7 +536,55 @@
"type": "object"
},
"snapshotId": { "type": "string" },
"source": { "enum": ["tdx"], "type": "string" },
"source": { "enum": ["tdx", "aktools", "mixed"], "type": "string" },
"status": {
"enum": ["fresh", "delayed", "partial", "unavailable"],
"type": "string"
},
"warnings": { "items": { "type": "string" }, "type": "array" }
},
"required": ["snapshotId", "asOf", "source", "status", "warnings", "data"],
"type": "object"
},
"TurnoverPointDto": {
"properties": {
"amount": {
"description": "该时间区间成交额,单位亿元",
"type": "number"
},
"cumulativeAmount": {
"description": "当日累计成交额,单位亿元",
"type": "number"
},
"time": { "format": "date-time", "type": "string" }
},
"required": ["time", "amount", "cumulativeAmount"],
"type": "object"
},
"TurnoverTrendDataDto": {
"properties": {
"granularity": { "enum": ["5", "15"], "type": "string" },
"invalidPointCount": { "type": "number" },
"marketCount": { "maximum": 3, "minimum": 1, "type": "number" },
"points": {
"items": { "$ref": "#/components/schemas/TurnoverPointDto" },
"type": "array"
},
"unit": { "example": "亿元", "type": "string" }
},
"required": ["points", "granularity", "unit", "marketCount", "invalidPointCount"],
"type": "object"
},
"TurnoverTrendResponseDto": {
"properties": {
"asOf": { "format": "date-time", "type": "string" },
"data": {
"allOf": [{ "$ref": "#/components/schemas/TurnoverTrendDataDto" }],
"nullable": true,
"type": "object"
},
"snapshotId": { "type": "string" },
"source": { "enum": ["tdx", "aktools", "mixed"], "type": "string" },
"status": {
"enum": ["fresh", "delayed", "partial", "unavailable"],
"type": "string"
@@ -475,6 +646,47 @@
"tags": ["market-overview"]
}
},
"/api/v1/market-overview/capital-flow": {
"get": {
"operationId": "MarketStructureController_getCapitalFlow",
"parameters": [
{
"in": "query",
"name": "limit",
"required": false,
"schema": { "maximum": 100, "minimum": 1, "type": "number" }
},
{
"in": "query",
"name": "sort",
"required": false,
"schema": { "enum": ["inflow", "outflow"], "type": "string" }
},
{
"in": "query",
"name": "category",
"required": false,
"schema": {
"enum": ["industry", "concept", "board"],
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CapitalFlowResponseDto"
}
}
},
"description": ""
}
},
"tags": ["market-overview-structure"]
}
},
"/api/v1/market-overview/funds": {
"get": {
"operationId": "MarketOverviewController_getFunds",
@@ -553,6 +765,47 @@
"tags": ["market-overview-detail"]
}
},
"/api/v1/market-overview/sector-rankings": {
"get": {
"operationId": "MarketStructureController_getSectorRankings",
"parameters": [
{
"in": "query",
"name": "limit",
"required": false,
"schema": { "maximum": 100, "minimum": 1, "type": "number" }
},
{
"in": "query",
"name": "sort",
"required": false,
"schema": { "enum": ["rise", "fall", "turnover"], "type": "string" }
},
{
"in": "query",
"name": "category",
"required": false,
"schema": {
"enum": ["industry", "concept", "board"],
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SectorRankingsResponseDto"
}
}
},
"description": ""
}
},
"tags": ["market-overview-structure"]
}
},
"/api/v1/market-overview/sectors": {
"get": {
"operationId": "MarketOverviewController_getSectors",
@@ -668,6 +921,38 @@
},
"tags": ["market-overview"]
}
},
"/api/v1/market-overview/turnover-trend": {
"get": {
"operationId": "MarketStructureController_getTurnoverTrend",
"parameters": [
{
"in": "query",
"name": "tradeDate",
"required": false,
"schema": { "pattern": "^\\d{8}$", "type": "string" }
},
{
"in": "query",
"name": "interval",
"required": false,
"schema": { "enum": ["5", "15"], "type": "string" }
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TurnoverTrendResponseDto"
}
}
},
"description": ""
}
},
"tags": ["market-overview-structure"]
}
}
},
"servers": [],
+16
View File
@@ -11,6 +11,12 @@ import { HealthController } from '../apps/api/src/health.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 { MarketStructureController } from '../apps/api/src/market-overview/structure/market-structure.controller';
import { createAktoolsStructureClient } from '../apps/api/src/market-overview/structure/aktools-structure.client';
import {
MARKET_STRUCTURE_SOURCE_PORT,
MarketStructureService,
} from '../apps/api/src/market-overview/structure/market-structure.service';
import {
HealthService,
MYSQL_HEALTH_PORT,
@@ -34,6 +40,7 @@ import { DisabledHealthClient } from '../libs/infrastructure/src/disabled-health
MarketOverviewController,
MarketDetailController,
ShortTermMarketController,
MarketStructureController,
],
providers: [
HealthService,
@@ -41,6 +48,7 @@ import { DisabledHealthClient } from '../libs/infrastructure/src/disabled-health
MarketDetailService,
{ provide: TDX_CLIENT_PORT, useValue: { request: async () => ({}) } },
ShortTermMarketService,
MarketStructureService,
{
provide: AKTOOLS_CLIENT_PORT,
useValue: createAktoolsClient({
@@ -49,6 +57,14 @@ import { DisabledHealthClient } from '../libs/infrastructure/src/disabled-health
timeoutMs: 10_000,
}),
},
{
provide: MARKET_STRUCTURE_SOURCE_PORT,
useValue: createAktoolsStructureClient({
enabled: false,
baseUrl: 'http://127.0.0.1:8080',
timeoutMs: 10_000,
}),
},
{ provide: REDIS_HEALTH_PORT, useValue: new DisabledHealthClient('文档生成不连接 Redis') },
{ provide: MYSQL_HEALTH_PORT, useValue: new DisabledHealthClient('文档生成不连接 MySQL') },
],
@@ -0,0 +1,283 @@
#!/usr/bin/env node
const candidates = Object.freeze([
'stock_board_industry_spot_em',
'stock_board_concept_spot_em',
'stock_sector_fund_flow_rank',
'stock_market_fund_flow',
'stock_zh_a_spot_em',
]);
const tdxSymbols = Object.freeze(['000001.SH', '399001.SZ', '899050.BJ']);
const tdxPeriods = Object.freeze(['1m', '5m', '15m', '1d']);
const DEFAULT_AKTOOLS_BASE_URL = 'http://127.0.0.1:8080';
const DEFAULT_TDX_BASE_URL = 'http://127.0.0.1:17710';
const TIMEOUT_MS = 10_000;
const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
function usage() {
return `市场结构能力探针(只读)\n\n用法:node tools/probes/market-structure-capability.mjs [--base-url http://127.0.0.1:8080]\n\n仅允许本机 AKTools(127.0.0.1:8080)及本机 TDX(127.0.0.1:17710)。输出不会包含原始行、凭据、请求体或异常详情。`;
}
function parseLocalBaseUrl(value, expectedPort, optionName) {
let parsed;
try {
parsed = new URL(value);
} catch {
throw new Error(`${optionName} 必须是有效 URL`);
}
if (
parsed.protocol !== 'http:' ||
parsed.hostname !== '127.0.0.1' ||
parsed.port !== expectedPort
) {
throw new Error(`${optionName} 必须指向本机 127.0.0.1:${expectedPort}`);
}
if (parsed.pathname !== '' && parsed.pathname !== '/') {
throw new Error(`${optionName} 不得包含路径`);
}
return parsed.toString().replace(/\/$/, '');
}
function parseArguments(argv) {
let baseUrl = DEFAULT_AKTOOLS_BASE_URL;
for (let index = 0; index < argv.length; index += 1) {
if (argv[index] === '--help' || argv[index] === '-h') return { help: true };
if (argv[index] === '--base-url' && argv[index + 1]) {
baseUrl = argv[index + 1];
index += 1;
continue;
}
throw new Error(`不支持的参数:${argv[index]}`);
}
return {
aktoolsBaseUrl: parseLocalBaseUrl(baseUrl, '8080', '--base-url'),
tdxBaseUrl: parseLocalBaseUrl(
process.env.TDX_BASE_URL || DEFAULT_TDX_BASE_URL,
'17710',
'TDX_BASE_URL',
),
};
}
function classifyError(error) {
if (error?.name === 'AbortError') return 'timeout';
if (typeof error?.message === 'string' && error.message.startsWith('http_')) return error.message;
if (error instanceof SyntaxError) return 'invalid_json';
return 'network';
}
function getRows(value) {
if (Array.isArray(value)) return value;
if (value && typeof value === 'object') {
for (const key of ['data', 'result', 'Value']) {
const candidate = value[key];
if (Array.isArray(candidate)) return candidate;
}
}
return [];
}
function getKeys(rows) {
return [
...new Set(rows.flatMap((row) => (row && typeof row === 'object' ? Object.keys(row) : []))),
].sort();
}
function getTimestamp(row) {
if (!row || typeof row !== 'object') return null;
for (const key of ['Date', 'date', 'datetime', 'time', '时间', '日期']) {
const value = row[key];
if (typeof value === 'string' || typeof value === 'number') return String(value);
}
return null;
}
function isIntradayTimestamp(timestamp) {
return typeof timestamp === 'string' && /(?:T|\s)\d{2}:\d{2}/.test(timestamp);
}
function isShanghaiTradingSession(now = new Date()) {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
weekday: 'short',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).formatToParts(now);
const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
if (values.weekday === 'Sat' || values.weekday === 'Sun') return false;
const minutes = Number(values.hour) * 60 + Number(values.minute);
return (
(minutes >= 9 * 60 + 30 && minutes <= 11 * 60 + 30) ||
(minutes >= 13 * 60 && minutes <= 15 * 60)
);
}
async function fetchJson(url, options) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const response = await fetch(url, { ...options, signal: controller.signal });
const contentLength = response.headers.get('content-length');
if (contentLength && Number(contentLength) > MAX_RESPONSE_BYTES)
throw new Error('http_response_too_large');
const text = await response.text();
if (Buffer.byteLength(text, 'utf8') > MAX_RESPONSE_BYTES)
throw new Error('http_response_too_large');
if (!response.ok) throw new Error(`http_${response.status}`);
return JSON.parse(text);
} finally {
clearTimeout(timeout);
}
}
function summarizeRows(rows) {
const firstTimestamp = getTimestamp(rows[0]);
const lastTimestamp = getTimestamp(rows.at(-1));
return {
rowCount: rows.length,
keys: getKeys(rows),
firstTimestamp,
lastTimestamp,
};
}
function summarizeTdxSeries(series) {
const dates = Array.isArray(series?.Date) ? series.Date : [];
return {
rowCount: dates.length,
keys: series && typeof series === 'object' ? Object.keys(series).sort() : [],
firstTimestamp: dates.length ? String(dates[0]) : null,
lastTimestamp: dates.length ? String(dates.at(-1)) : null,
};
}
async function probeAktools(baseUrl, endpoint) {
const startedAt = performance.now();
try {
const value = await fetchJson(`${baseUrl}/api/public/${endpoint}`, {
headers: { accept: 'application/json' },
});
const rows = getRows(value);
return {
source: 'aktools',
endpoint,
status: rows.length > 0 ? 'usable' : 'partial',
elapsedMs: Math.round(performance.now() - startedAt),
...summarizeRows(rows),
errorClass: null,
};
} catch (error) {
return {
source: 'aktools',
endpoint,
status: 'unavailable',
elapsedMs: Math.round(performance.now() - startedAt),
rowCount: 0,
keys: [],
firstTimestamp: null,
lastTimestamp: null,
errorClass: classifyError(error),
};
}
}
function analyzeTdxRows(series) {
const dates = Array.isArray(series?.Date) ? series.Date : [];
const amounts = Array.isArray(series?.Amount) ? series.Amount : [];
return {
dateAmountAligned: dates.length > 0 && dates.length === amounts.length,
timestampsIntraday:
dates.length > 0 && dates.every((date) => isIntradayTimestamp(String(date))),
};
}
async function probeTdx(baseUrl, symbol, period) {
const startedAt = performance.now();
try {
const envelope = await fetchJson(`${baseUrl}/`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
id: 1,
method: 'get_market_data',
params: { stock_list: [symbol], count: 30, dividend_type: 'none', period },
}),
});
const series = envelope?.result?.Value?.[symbol];
const businessError =
envelope?.error !== undefined ||
!envelope?.result ||
(envelope.result.ErrorId !== undefined &&
envelope.result.ErrorId !== 0 &&
envelope.result.ErrorId !== '0');
const summary = summarizeTdxSeries(series);
return {
source: 'tdx',
symbol,
period,
status: businessError ? 'unavailable' : summary.rowCount > 0 ? 'usable' : 'partial',
elapsedMs: Math.round(performance.now() - startedAt),
...summary,
...analyzeTdxRows(series),
errorClass: businessError ? 'protocol' : null,
};
} catch (error) {
return {
source: 'tdx',
symbol,
period,
status: 'unavailable',
elapsedMs: Math.round(performance.now() - startedAt),
rowCount: 0,
keys: [],
firstTimestamp: null,
lastTimestamp: null,
dateAmountAligned: false,
timestampsIntraday: false,
errorClass: classifyError(error),
};
}
}
async function main() {
const options = parseArguments(process.argv.slice(2));
if (options.help) {
console.log(usage());
return;
}
const aktools = await Promise.all(
candidates.map((endpoint) => probeAktools(options.aktoolsBaseUrl, endpoint)),
);
const tdx = await Promise.all(
tdxSymbols.flatMap((symbol) =>
tdxPeriods.map((period) => probeTdx(options.tdxBaseUrl, symbol, period)),
),
);
console.log(
JSON.stringify(
{
observedAt: new Date().toISOString(),
aktoolsBaseUrl: options.aktoolsBaseUrl,
tdxBaseUrl: options.tdxBaseUrl,
tradingSession: isShanghaiTradingSession(),
freshnessNote: isShanghaiTradingSession()
? '盘中观测:最后时间可用于后续新鲜度判断。'
: '非盘中观测:最后一条记录仅表示收盘后最后记录,不能证明盘中新鲜度。',
aktools,
tdx,
},
null,
2,
),
);
}
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((error) => {
console.error(JSON.stringify({ errorClass: 'configuration', message: error.message }));
process.exitCode = 1;
});
}
export { analyzeTdxRows, isShanghaiTradingSession, parseArguments, summarizeRows };
@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { spawnSync } from 'node:child_process';
import { analyzeTdxRows, parseArguments } from './market-structure-capability.mjs';
test('帮助信息声明固定的本机数据源边界', () => {
const result = spawnSync(
process.execPath,
['tools/probes/market-structure-capability.mjs', '--help'],
{
cwd: new URL('../..', import.meta.url),
encoding: 'utf8',
},
);
assert.equal(result.status, 0);
assert.match(result.stdout, /127\.0\.0\.1:8080/);
assert.match(result.stdout, /只读/);
});
test('TDX 列式序列在 Date 与 Amount 等长且分钟时间戳完整时标记为对齐', () => {
assert.deepEqual(
analyzeTdxRows({
Date: ['2026-08-25 09:30:00', '2026-08-25 09:35:00'],
Amount: ['10', '12'],
}),
{ dateAmountAligned: true, timestampsIntraday: true },
);
});
test('探针拒绝非回环地址和错误端口', () => {
assert.throws(
() => parseArguments(['--base-url', 'http://192.168.1.10:8080']),
/127\.0\.0\.1:8080/,
);
assert.throws(() => parseArguments(['--base-url', 'http://127.0.0.1:9000']), /127\.0\.0\.1:8080/);
});
+215 -7
View File
@@ -36,6 +36,22 @@ export interface paths {
patch?: never;
trace?: never;
};
'/api/v1/market-overview/capital-flow': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations['MarketStructureController_getCapitalFlow'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/market-overview/funds': {
parameters: {
query?: never;
@@ -84,6 +100,22 @@ export interface paths {
patch?: never;
trace?: never;
};
'/api/v1/market-overview/sector-rankings': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations['MarketStructureController_getSectorRankings'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/market-overview/sectors': {
parameters: {
query?: never;
@@ -164,6 +196,22 @@ export interface paths {
patch?: never;
trace?: never;
};
'/api/v1/market-overview/turnover-trend': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations['MarketStructureController_getTurnoverTrend'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
@@ -179,7 +227,39 @@ export interface components {
data: components['schemas']['BreadthDataDto'] | null;
snapshotId: string;
/** @enum {string} */
source: 'tdx';
source: 'tdx' | 'aktools' | 'mixed';
/** @enum {string} */
status: 'fresh' | 'delayed' | 'partial' | 'unavailable';
warnings: string[];
};
CapitalFlowDataDto: {
/** @enum {string} */
category: 'industry' | 'concept' | 'board';
items: components['schemas']['CapitalFlowItemDto'][];
/** @enum {string} */
sort: 'inflow' | 'outflow';
};
CapitalFlowItemDto: {
/** @enum {string} */
category: 'industry' | 'concept' | 'board';
code?: string | null;
flowDefinition: string;
name: string;
/** @description 亿元 */
netInflow?: number | null;
/** @description 亿元 */
netOutflow?: number | null;
tradeDate?: string | null;
/** @description 亿元 */
turnoverAmount?: number | null;
};
CapitalFlowResponseDto: {
/** Format: date-time */
asOf: string;
data: components['schemas']['CapitalFlowDataDto'] | null;
snapshotId: string;
/** @enum {string} */
source: 'tdx' | 'aktools' | 'mixed';
/** @enum {string} */
status: 'fresh' | 'delayed' | 'partial' | 'unavailable';
warnings: string[];
@@ -235,7 +315,7 @@ export interface components {
data: components['schemas']['FundDataDto'] | null;
snapshotId: string;
/** @enum {string} */
source: 'tdx';
source: 'tdx' | 'aktools' | 'mixed';
/** @enum {string} */
status: 'fresh' | 'delayed' | 'partial' | 'unavailable';
warnings: string[];
@@ -266,7 +346,7 @@ export interface components {
data: components['schemas']['IndexDetailDataDto'] | null;
snapshotId: string;
/** @enum {string} */
source: 'tdx';
source: 'tdx' | 'aktools' | 'mixed';
/** @enum {string} */
status: 'fresh' | 'delayed' | 'partial' | 'unavailable';
warnings: string[];
@@ -289,7 +369,7 @@ export interface components {
data: components['schemas']['IndicesDataDto'] | null;
snapshotId: string;
/** @enum {string} */
source: 'tdx';
source: 'tdx' | 'aktools' | 'mixed';
/** @enum {string} */
status: 'fresh' | 'delayed' | 'partial' | 'unavailable';
warnings: string[];
@@ -309,7 +389,7 @@ export interface components {
data: components['schemas']['SectorDetailDataDto'] | null;
snapshotId: string;
/** @enum {string} */
source: 'tdx';
source: 'tdx' | 'aktools' | 'mixed';
/** @enum {string} */
status: 'fresh' | 'delayed' | 'partial' | 'unavailable';
warnings: string[];
@@ -322,13 +402,45 @@ export interface components {
name: string;
tradeDate: string;
};
SectorRankingItemDto: {
advancingCount?: number | null;
/** @enum {string} */
category: 'industry' | 'concept' | 'board';
changePercent?: number | null;
code: string;
decliningCount?: number | null;
leader?: string | null;
name: string;
/** @example 20260825 */
tradeDate?: string | null;
/** @description 亿元 */
turnoverAmount?: number | null;
};
SectorRankingsDataDto: {
/** @enum {string} */
category: 'industry' | 'concept' | 'board';
items: components['schemas']['SectorRankingItemDto'][];
/** @enum {string} */
sort: 'rise' | 'fall' | 'turnover';
};
SectorRankingsResponseDto: {
/** Format: date-time */
asOf: string;
data: components['schemas']['SectorRankingsDataDto'] | null;
snapshotId: string;
/** @enum {string} */
source: 'tdx' | 'aktools' | 'mixed';
/** @enum {string} */
status: 'fresh' | 'delayed' | 'partial' | 'unavailable';
warnings: string[];
};
SectorsResponseDto: {
/** Format: date-time */
asOf: string;
data: components['schemas']['SectorDataDto'] | null;
snapshotId: string;
/** @enum {string} */
source: 'tdx';
source: 'tdx' | 'aktools' | 'mixed';
/** @enum {string} */
status: 'fresh' | 'delayed' | 'partial' | 'unavailable';
warnings: string[];
@@ -401,7 +513,35 @@ export interface components {
data: components['schemas']['SummaryDataDto'] | null;
snapshotId: string;
/** @enum {string} */
source: 'tdx';
source: 'tdx' | 'aktools' | 'mixed';
/** @enum {string} */
status: 'fresh' | 'delayed' | 'partial' | 'unavailable';
warnings: string[];
};
TurnoverPointDto: {
/** @description 该时间区间成交额,单位亿元 */
amount: number;
/** @description 当日累计成交额,单位亿元 */
cumulativeAmount: number;
/** Format: date-time */
time: string;
};
TurnoverTrendDataDto: {
/** @enum {string} */
granularity: '5' | '15';
invalidPointCount: number;
marketCount: number;
points: components['schemas']['TurnoverPointDto'][];
/** @example 亿元 */
unit: string;
};
TurnoverTrendResponseDto: {
/** Format: date-time */
asOf: string;
data: components['schemas']['TurnoverTrendDataDto'] | null;
snapshotId: string;
/** @enum {string} */
source: 'tdx' | 'aktools' | 'mixed';
/** @enum {string} */
status: 'fresh' | 'delayed' | 'partial' | 'unavailable';
warnings: string[];
@@ -461,6 +601,29 @@ export interface operations {
};
};
};
MarketStructureController_getCapitalFlow: {
parameters: {
query?: {
limit?: number;
sort?: 'inflow' | 'outflow';
category?: 'industry' | 'concept' | 'board';
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['CapitalFlowResponseDto'];
};
};
};
};
MarketOverviewController_getFunds: {
parameters: {
query?: never;
@@ -524,6 +687,29 @@ export interface operations {
};
};
};
MarketStructureController_getSectorRankings: {
parameters: {
query?: {
limit?: number;
sort?: 'rise' | 'fall' | 'turnover';
category?: 'industry' | 'concept' | 'board';
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['SectorRankingsResponseDto'];
};
};
};
};
MarketOverviewController_getSectors: {
parameters: {
query?: never;
@@ -625,4 +811,26 @@ export interface operations {
};
};
};
MarketStructureController_getTurnoverTrend: {
parameters: {
query?: {
tradeDate?: string;
interval?: '5' | '15';
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['TurnoverTrendResponseDto'];
};
};
};
};
}
@@ -17,6 +17,9 @@ describe('市场概览业务 API', () => {
await api.getBreadth();
await api.getSectors();
await api.getFunds();
await api.getSectorRankings({ category: 'industry', sort: 'rise', limit: 10 });
await api.getCapitalFlow({ category: 'industry', sort: 'inflow', limit: 10 });
await api.getTurnoverTrend({ interval: '5' });
await api.getShortTermEmotion();
await api.getShortTermConclusion();
@@ -26,6 +29,9 @@ describe('市场概览业务 API', () => {
'/api/v1/market-overview/breadth',
'/api/v1/market-overview/sectors',
'/api/v1/market-overview/funds',
'/api/v1/market-overview/sector-rankings',
'/api/v1/market-overview/capital-flow',
'/api/v1/market-overview/turnover-trend',
'/api/v1/market-overview/short-term/emotion',
'/api/v1/market-overview/short-term/conclusion',
]);
+2
View File
@@ -8,9 +8,11 @@ import type {
ShortTermEmotionResponse,
SummaryResponse,
} from '@/types/market-overview';
import { createMarketStructureApi } from './structure';
export function createMarketOverviewApi(requestClient: RequestClient = request) {
return {
...createMarketStructureApi(requestClient),
getSummary: () =>
requestClient<SummaryResponse>({ method: 'get', url: '/api/v1/market-overview/summary' }),
getIndices: () =>
@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from 'vitest';
import type { RequestClient } from '@/utils/request';
import { createMarketStructureApi } from './structure';
describe('市场结构业务 API', () => {
it('使用独立版本化路径和白名单查询参数', async () => {
const requestMock = vi.fn((config: Parameters<RequestClient>[0]) => {
void config;
return Promise.resolve({});
});
const api = createMarketStructureApi(requestMock as unknown as RequestClient);
await api.getSectorRankings({ category: 'industry', sort: 'rise', limit: 10 });
await api.getCapitalFlow({ category: 'concept', sort: 'outflow', limit: 20 });
await api.getTurnoverTrend({ interval: '5', tradeDate: '20260825' });
expect(requestMock.mock.calls).toEqual([
[
expect.objectContaining({
method: 'get',
url: '/api/v1/market-overview/sector-rankings',
params: { category: 'industry', sort: 'rise', limit: 10 },
}),
],
[
expect.objectContaining({
method: 'get',
url: '/api/v1/market-overview/capital-flow',
params: { category: 'concept', sort: 'outflow', limit: 20 },
}),
],
[
expect.objectContaining({
method: 'get',
url: '/api/v1/market-overview/turnover-trend',
params: { interval: '5', tradeDate: '20260825' },
}),
],
]);
});
});
+51
View File
@@ -0,0 +1,51 @@
import request, { type RequestClient } from '@/utils/request';
import type { components } from '@/api/generated/schema';
export type SectorRankingsResponse = components['schemas']['SectorRankingsResponseDto'];
export type CapitalFlowResponse = components['schemas']['CapitalFlowResponseDto'];
export type TurnoverTrendResponse = components['schemas']['TurnoverTrendResponseDto'];
export interface SectorRankingsQuery {
category: 'industry' | 'concept' | 'board';
sort: 'rise' | 'fall' | 'turnover';
limit: number;
}
export interface CapitalFlowQuery {
category: 'industry' | 'concept' | 'board';
sort: 'inflow' | 'outflow';
limit: number;
}
export interface TurnoverTrendQuery {
interval: '5' | '15';
tradeDate?: string;
}
/** 市场结构请求只调用固定的 Nest 路径,页面不感知 AKTools/TDX 地址。 */
export function createMarketStructureApi(requestClient: RequestClient = request) {
return {
getSectorRankings: (params: SectorRankingsQuery) =>
requestClient<SectorRankingsResponse>({
method: 'get',
url: '/api/v1/market-overview/sector-rankings',
params,
}),
getCapitalFlow: (params: CapitalFlowQuery) =>
requestClient<CapitalFlowResponse>({
method: 'get',
url: '/api/v1/market-overview/capital-flow',
params,
}),
getTurnoverTrend: (params: TurnoverTrendQuery) =>
requestClient<TurnoverTrendResponse>({
method: 'get',
url: '/api/v1/market-overview/turnover-trend',
params,
}),
};
}
const marketStructureApi = createMarketStructureApi();
export default marketStructureApi;
+137 -1
View File
@@ -542,6 +542,10 @@ textarea {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.overview-grid-structure {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.overview-panel {
min-height: 190px;
padding: 18px;
@@ -556,6 +560,125 @@ textarea {
white-space: nowrap;
}
.structure-panel {
min-width: 0;
}
.structure-tabs,
.structure-sort {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-bottom: 10px;
}
.structure-tabs button,
.structure-sort button {
min-height: 28px;
padding: 4px 9px;
color: #62717d;
background: #f2f5f6;
border: 1px solid #dfe6e9;
border-radius: 4px;
cursor: pointer;
font: inherit;
font-size: 11px;
}
.structure-tabs button.active,
.structure-sort button.active {
color: #11686c;
background: #e8f5f3;
border-color: #a9d3cf;
font-weight: 700;
}
.structure-list {
display: grid;
gap: 5px;
}
.structure-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 8px;
width: 100%;
min-height: 31px;
padding: 5px 7px;
color: #3e4d58;
background: #f7f9fa;
border: 1px solid transparent;
border-radius: 3px;
cursor: pointer;
font: inherit;
text-align: left;
}
.structure-row:hover {
border-color: #b9d7d4;
background: #f0f7f6;
}
.structure-row-static {
cursor: default;
}
.structure-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.structure-value {
color: #c33f3f;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.structure-value[data-direction='down'] {
color: #19846e;
}
.structure-row small {
max-width: 84px;
overflow: hidden;
color: #87939c;
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.turnover-summary {
display: flex;
align-items: baseline;
gap: 7px;
margin-top: 12px;
}
.turnover-summary strong {
color: #1c2a35;
font-size: 25px;
}
.turnover-summary span {
color: #7d8893;
font-size: 12px;
}
.turnover-chart {
display: block;
width: 100%;
height: 150px;
margin-top: 13px;
overflow: visible;
background: #f7f9fa;
border: 1px solid #e3e9ec;
border-radius: 4px;
}
.quiet-button {
padding: 6px 9px;
color: #167d82;
@@ -838,7 +961,8 @@ textarea {
@media (max-width: 900px) {
.overview-grid-top,
.overview-grid-bottom {
.overview-grid-bottom,
.overview-grid-structure {
grid-template-columns: 1fr;
}
}
@@ -896,6 +1020,18 @@ textarea {
grid-template-columns: 1fr;
}
.structure-row {
grid-template-columns: minmax(0, 1fr) auto;
}
.structure-row small {
display: none;
}
.turnover-chart {
height: 130px;
}
.driver-column + .driver-column {
padding-top: 14px;
padding-left: 0;
+6
View File
@@ -7,6 +7,9 @@ export type SectorsResponse = components['schemas']['SectorsResponseDto'];
export type FundsResponse = components['schemas']['FundsResponseDto'];
export type ShortTermEmotionResponse = components['schemas']['ShortTermEmotionResponseDto'];
export type ShortTermConclusionResponse = components['schemas']['ShortTermConclusionResponseDto'];
export type SectorRankingsResponse = components['schemas']['SectorRankingsResponseDto'];
export type CapitalFlowResponse = components['schemas']['CapitalFlowResponseDto'];
export type TurnoverTrendResponse = components['schemas']['TurnoverTrendResponseDto'];
export type SummaryData = NonNullable<SummaryResponse['data']>;
export type IndicesData = NonNullable<IndicesResponse['data']>;
export type BreadthData = NonNullable<BreadthResponse['data']>;
@@ -14,5 +17,8 @@ export type SectorsData = NonNullable<SectorsResponse['data']>;
export type FundsData = NonNullable<FundsResponse['data']>;
export type ShortTermEmotionData = NonNullable<ShortTermEmotionResponse['data']>;
export type ShortTermConclusionData = NonNullable<ShortTermConclusionResponse['data']>;
export type SectorRankingsData = NonNullable<SectorRankingsResponse['data']>;
export type CapitalFlowData = NonNullable<CapitalFlowResponse['data']>;
export type TurnoverTrendData = NonNullable<TurnoverTrendResponse['data']>;
export type OverviewModuleStatus = 'idle' | 'loading' | 'ready' | 'error';
@@ -9,6 +9,9 @@ import SectorOverview from './components/SectorOverview.vue';
import FundTrend from './components/FundTrend.vue';
import IndexOverview from './components/IndexOverview.vue';
import ShortTermEmotion from './components/ShortTermEmotion.vue';
import SectorRankingPanel from './components/SectorRankingPanel.vue';
import CapitalFlowPanel from './components/CapitalFlowPanel.vue';
import TurnoverTrendPanel from './components/TurnoverTrendPanel.vue';
import { useMarketOverview } from './useMarketOverview';
const {
@@ -17,12 +20,19 @@ const {
breadth,
sectors,
funds,
sectorRankings,
capitalFlow,
turnoverTrend,
shortTermEmotion,
shortTermConclusion,
sectorCategory,
capitalFlowCategory,
isLoading,
analysisHorizon,
analysisEngine,
refresh,
refreshSectorRankings,
refreshCapitalFlow,
setAnalysisHorizon,
setAnalysisEngine,
} = useMarketOverview();
@@ -99,6 +109,26 @@ function openSectorDetail(code: string) {
@select="openSectorDetail"
/>
<FundTrend :response="funds.response" :loading="funds.status === 'loading'" />
</div>
<div class="overview-grid overview-grid-structure">
<SectorRankingPanel
:response="sectorRankings.response"
:loading="sectorRankings.status === 'loading'"
:category="sectorCategory"
@category-change="refreshSectorRankings"
@select="openSectorDetail"
/>
<CapitalFlowPanel
:response="capitalFlow.response"
:loading="capitalFlow.status === 'loading'"
:category="capitalFlowCategory"
@category-change="refreshCapitalFlow"
/>
<TurnoverTrendPanel
:response="turnoverTrend.response"
:loading="turnoverTrend.status === 'loading'"
/>
<ShortTermEmotion
:response="shortTermEmotion.response"
:loading="shortTermEmotion.status === 'loading'"
@@ -0,0 +1,97 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import type { CapitalFlowResponse } from '@/types/market-overview';
type Category = 'industry' | 'concept' | 'board';
type Sort = 'inflow' | 'outflow';
const props = defineProps<{
response: CapitalFlowResponse | null;
loading: boolean;
category: Category;
}>();
const emit = defineEmits<{ 'category-change': [category: Category] }>();
const sort = ref<Sort>('inflow');
const categoryLabels: Record<Category, string> = {
industry: '行业',
concept: '题材',
board: '板块',
};
const items = computed(() => {
const source = props.response?.data?.items ?? [];
return [...source].sort((left, right) => {
const leftValue = left.netInflow ?? Number.NEGATIVE_INFINITY;
const rightValue = right.netInflow ?? Number.NEGATIVE_INFINITY;
return sort.value === 'inflow' ? rightValue - leftValue : leftValue - rightValue;
});
});
function formatFlow(value: number | null | undefined): string {
return value == null ? '未提供' : `${value > 0 ? '+' : ''}${value.toFixed(2)} 亿`;
}
</script>
<template>
<section class="overview-panel structure-panel" aria-labelledby="capital-flow-title">
<div class="panel-heading">
<div>
<span class="section-kicker">资金方向</span>
<h2 id="capital-flow-title">{{ categoryLabels[category] }}资金流向</h2>
</div>
<span class="panel-meta">净额口径</span>
</div>
<div class="structure-tabs" role="tablist" aria-label="资金分类">
<button
v-for="(label, value) in categoryLabels"
:key="value"
type="button"
:data-category="value"
:class="{ active: category === value }"
@click="emit('category-change', value as Category)"
>
{{ label }}
</button>
</div>
<div class="structure-sort" role="group" aria-label="资金排序">
<button type="button" :class="{ active: sort === 'inflow' }" @click="sort = 'inflow'">
净流入
</button>
<button type="button" :class="{ active: sort === 'outflow' }" @click="sort = 'outflow'">
净流出
</button>
</div>
<div v-if="loading" class="module-placeholder">正在读取资金流向…</div>
<div v-else-if="response?.status === 'unavailable'" class="module-unavailable">
<strong>暂不可用</strong>
<span>{{ response.warnings[0] }}</span>
</div>
<template v-else-if="items.length">
<div v-if="response?.status !== 'fresh'" class="module-warning">
<strong>{{ response?.status === 'partial' ? '部分数据' : '数据延迟' }}</strong>
<span>{{ response?.warnings[0] }}</span>
</div>
<div class="structure-list">
<div
v-for="item in items"
:key="item.code ?? item.name"
class="structure-row structure-row-static"
>
<span class="structure-name">{{ item.name }}</span>
<strong
class="structure-value"
:data-direction="(item.netInflow ?? 0) >= 0 ? 'up' : 'down'"
>
{{ formatFlow(item.netInflow) }}
</strong>
<small>{{
item.flowDefinition === 'unavailable' ? '口径未提供' : item.flowDefinition
}}</small>
</div>
</div>
</template>
<div v-else class="module-placeholder">暂无资金流向数据</div>
</section>
</template>
@@ -0,0 +1,114 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import CapitalFlowPanel from './CapitalFlowPanel.vue';
import SectorRankingPanel from './SectorRankingPanel.vue';
import TurnoverTrendPanel from './TurnoverTrendPanel.vue';
const meta = {
snapshotId: '20260825-103000',
asOf: '2026-08-25T10:30:00+08:00',
source: 'aktools' as const,
status: 'fresh' as const,
warnings: [],
};
describe('市场结构首屏面板', () => {
it('展示行业排行并支持类别切换和详情事件', async () => {
const wrapper = mount(SectorRankingPanel, {
props: {
loading: false,
category: 'industry' as const,
response: {
...meta,
data: {
category: 'industry' as const,
sort: 'rise' as const,
items: [
{
category: 'industry' as const,
code: 'BK1',
name: '半导体',
changePercent: 3.2,
turnoverAmount: 12.3,
advancingCount: 20,
decliningCount: 2,
leader: '测试股份',
tradeDate: '20260825',
},
],
},
},
},
});
expect(wrapper.text()).toContain('半导体');
expect(wrapper.text()).toContain('+3.20%');
await wrapper.get('[data-category="concept"]').trigger('click');
expect(wrapper.emitted('category-change')).toEqual([['concept']]);
await wrapper.get('[data-code="BK1"]').trigger('click');
expect(wrapper.emitted('select')).toEqual([['BK1']]);
});
it('资金流向缺失时显示 partial 警告且不显示零净流入', () => {
const wrapper = mount(CapitalFlowPanel, {
props: {
loading: false,
category: 'industry' as const,
response: {
...meta,
status: 'partial' as const,
warnings: ['部分资金流向字段不可用'],
data: {
category: 'industry' as const,
sort: 'inflow' as const,
items: [
{
category: 'industry' as const,
code: 'BK1',
name: '未知',
netInflow: null,
netOutflow: null,
turnoverAmount: 1,
tradeDate: null,
flowDefinition: 'unavailable',
},
],
},
},
},
});
expect(wrapper.text()).toContain('部分数据');
expect(wrapper.text()).toContain('部分资金流向字段不可用');
expect(wrapper.text()).toContain('未提供');
expect(wrapper.text()).not.toContain('0.00');
});
it('成交额趋势绘制非空 SVG 并展示单位', () => {
const wrapper = mount(TurnoverTrendPanel, {
props: {
loading: false,
response: {
...meta,
source: 'tdx' as const,
data: {
granularity: '5' as const,
unit: '亿元' as const,
marketCount: 3,
invalidPointCount: 0,
points: [
{ time: '2026-08-25 09:30:00', amount: 1, cumulativeAmount: 1 },
{ time: '2026-08-25 09:35:00', amount: 2, cumulativeAmount: 3 },
{ time: '2026-08-25 09:40:00', amount: 1.5, cumulativeAmount: 4.5 },
],
},
},
},
});
expect(wrapper.text()).toContain('亿元');
expect(wrapper.find('svg').exists()).toBe(true);
expect(wrapper.find('polyline').attributes('points')).not.toBe('');
});
});
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import type { SectorRankingsResponse } from '@/types/market-overview';
type Category = 'industry' | 'concept' | 'board';
type Sort = 'rise' | 'fall' | 'turnover';
const props = defineProps<{
response: SectorRankingsResponse | null;
loading: boolean;
category: Category;
}>();
const emit = defineEmits<{
select: [code: string];
'category-change': [category: Category];
}>();
const sort = ref<Sort>('rise');
const categoryLabels: Record<Category, string> = {
industry: '行业',
concept: '题材',
board: '板块',
};
const sortLabels: Record<Sort, string> = { rise: '涨幅', fall: '跌幅', turnover: '成交额' };
const items = computed(() => {
const source = props.response?.data?.items ?? [];
return [...source].sort((left, right) => {
const leftValue = sort.value === 'turnover' ? left.turnoverAmount : left.changePercent;
const rightValue = sort.value === 'turnover' ? right.turnoverAmount : right.changePercent;
const leftNumber = leftValue ?? Number.NEGATIVE_INFINITY;
const rightNumber = rightValue ?? Number.NEGATIVE_INFINITY;
const difference = sort.value === 'fall' ? leftNumber - rightNumber : rightNumber - leftNumber;
return difference || left.name.localeCompare(right.name, 'zh-CN');
});
});
function formatPercent(value: number | null | undefined): string {
return value == null ? '—' : `${value > 0 ? '+' : ''}${value.toFixed(2)}%`;
}
function formatAmount(value: number | null | undefined): string {
return value == null ? '—' : `${value.toFixed(2)} 亿`;
}
</script>
<template>
<section class="overview-panel structure-panel" aria-labelledby="sector-ranking-title">
<div class="panel-heading">
<div>
<span class="section-kicker">结构扫描</span>
<h2 id="sector-ranking-title">{{ categoryLabels[category] }}强弱排行</h2>
</div>
<span class="panel-meta">Top {{ response?.data?.items.length ?? 0 }}</span>
</div>
<div class="structure-tabs" role="tablist" aria-label="板块分类">
<button
v-for="(label, value) in categoryLabels"
:key="value"
type="button"
:data-category="value"
:class="{ active: category === value }"
@click="emit('category-change', value as Category)"
>
{{ label }}
</button>
</div>
<div class="structure-sort" role="group" aria-label="排行口径">
<button
v-for="(label, value) in sortLabels"
:key="value"
type="button"
:class="{ active: sort === value }"
@click="sort = value as Sort"
>
{{ label }}
</button>
</div>
<div v-if="loading" class="module-placeholder">正在读取{{ categoryLabels[category] }}排行…</div>
<div v-else-if="response?.status === 'unavailable'" class="module-unavailable">
<strong>暂不可用</strong>
<span>{{ response.warnings[0] }}</span>
</div>
<template v-else-if="items.length">
<div v-if="response?.status !== 'fresh'" class="module-warning">
<strong>{{ response?.status === 'partial' ? '部分数据' : '数据延迟' }}</strong>
<span>{{ response?.warnings[0] }}</span>
</div>
<div class="structure-list">
<button
v-for="item in items"
:key="item.code"
type="button"
class="structure-row"
:data-code="item.code"
:title="`进入${item.name}详细分析`"
@click="emit('select', item.code)"
>
<span class="structure-name">{{ item.name }}</span>
<span class="structure-value" data-direction="up">{{
formatPercent(item.changePercent)
}}</span>
<small>{{
sort === 'turnover'
? formatAmount(item.turnoverAmount)
: formatAmount(item.turnoverAmount)
}}</small>
</button>
</div>
</template>
<div v-else class="module-placeholder">暂无{{ categoryLabels[category] }}排行数据</div>
</section>
</template>
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { TurnoverTrendResponse } from '@/types/market-overview';
const props = defineProps<{ response: TurnoverTrendResponse | null; loading: boolean }>();
const chartPoints = computed(() => {
const points = props.response?.data?.points ?? [];
if (!points.length) return '';
const max = Math.max(...points.map((point) => point.cumulativeAmount), 1);
const width = 620;
const height = 150;
return points
.map((point, index) => {
const x = points.length === 1 ? width / 2 : (index / (points.length - 1)) * width;
const y = height - (point.cumulativeAmount / max) * height;
return `${x.toFixed(2)},${y.toFixed(2)}`;
})
.join(' ');
});
const latest = computed(() => props.response?.data?.points.at(-1)?.cumulativeAmount ?? null);
</script>
<template>
<section
class="overview-panel structure-panel turnover-panel"
aria-labelledby="turnover-trend-title"
>
<div class="panel-heading">
<div>
<span class="section-kicker">量能观察</span>
<h2 id="turnover-trend-title">全天成交额趋势</h2>
</div>
<span class="panel-meta">{{ response?.data?.granularity ?? '—' }} 分钟</span>
</div>
<div v-if="loading" class="module-placeholder">正在读取成交额趋势…</div>
<div v-else-if="response?.status === 'unavailable'" class="module-unavailable">
<strong>暂不可用</strong>
<span>{{ response.warnings[0] }}</span>
</div>
<template v-else-if="response?.data?.points.length">
<div v-if="response.status !== 'fresh'" class="module-warning">
<strong>{{ response.status === 'partial' ? '部分数据' : '数据延迟' }}</strong>
<span>{{ response.warnings[0] }}</span>
</div>
<div class="turnover-summary">
<strong>{{ latest?.toFixed(2) ?? '—' }}</strong>
<span>{{ response.data.unit }}累计成交额</span>
</div>
<svg class="turnover-chart" viewBox="0 0 640 180" role="img" aria-label="全天累计成交额趋势">
<polyline
:points="chartPoints"
fill="none"
stroke="#167d82"
stroke-width="3"
vector-effect="non-scaling-stroke"
/>
</svg>
</template>
<div v-else class="module-placeholder">暂无成交额趋势数据</div>
</section>
</template>
@@ -19,6 +19,9 @@ describe('市场概览状态', () => {
getBreadth: vi.fn().mockResolvedValue(response),
getSectors: vi.fn().mockResolvedValue(response),
getFunds: vi.fn().mockResolvedValue(response),
getSectorRankings: vi.fn().mockResolvedValue(response),
getCapitalFlow: vi.fn().mockResolvedValue(response),
getTurnoverTrend: vi.fn().mockResolvedValue(response),
getShortTermEmotion: vi.fn().mockResolvedValue(response),
getShortTermConclusion: vi.fn().mockResolvedValue(response),
};
@@ -32,6 +35,9 @@ describe('市场概览状态', () => {
expect(state.isLoading.value).toBe(false);
expect(state.shortTermEmotion.value.status).toBe('ready');
expect(state.shortTermConclusion.value.status).toBe('ready');
expect(state.sectorRankings.value.status).toBe('ready');
expect(state.capitalFlow.value.status).toBe('ready');
expect(state.turnoverTrend.value.status).toBe('ready');
});
it('分析偏好写入本地存储并可恢复', () => {
@@ -44,4 +50,28 @@ describe('市场概览状态', () => {
expect(second.analysisHorizon.value).toBe('短线');
expect(second.analysisEngine.value).toBe('混合');
});
it('切换行业结构分类时重新请求对应数据', async () => {
const api = {
getSectorRankings: vi.fn().mockResolvedValue(response),
getCapitalFlow: vi.fn().mockResolvedValue(response),
};
const state = useMarketOverview(api as never);
await state.refreshSectorRankings('concept');
await state.refreshCapitalFlow('board');
expect(api.getSectorRankings).toHaveBeenCalledWith({
category: 'concept',
sort: 'rise',
limit: 10,
});
expect(api.getCapitalFlow).toHaveBeenCalledWith({
category: 'board',
sort: 'inflow',
limit: 10,
});
expect(state.sectorCategory.value).toBe('concept');
expect(state.capitalFlowCategory.value).toBe('board');
});
});
@@ -7,6 +7,9 @@ import type {
IndicesResponse,
OverviewModuleStatus,
SectorsResponse,
SectorRankingsResponse,
CapitalFlowResponse,
TurnoverTrendResponse,
ShortTermConclusionResponse,
ShortTermEmotionResponse,
SummaryResponse,
@@ -19,7 +22,12 @@ type MarketResponse =
| SectorsResponse
| FundsResponse
| ShortTermEmotionResponse
| ShortTermConclusionResponse;
| ShortTermConclusionResponse
| SectorRankingsResponse
| CapitalFlowResponse
| TurnoverTrendResponse;
export type MarketStructureCategory = 'industry' | 'concept' | 'board';
export interface OverviewModuleState<TResponse extends MarketResponse> {
status: OverviewModuleStatus;
@@ -54,6 +62,11 @@ export function useMarketOverview(
const breadth = ref<OverviewModuleState<BreadthResponse>>(createState());
const sectors = ref<OverviewModuleState<SectorsResponse>>(createState());
const funds = ref<OverviewModuleState<FundsResponse>>(createState());
const sectorRankings = ref<OverviewModuleState<SectorRankingsResponse>>(createState());
const capitalFlow = ref<OverviewModuleState<CapitalFlowResponse>>(createState());
const turnoverTrend = ref<OverviewModuleState<TurnoverTrendResponse>>(createState());
const sectorCategory = ref<MarketStructureCategory>('industry');
const capitalFlowCategory = ref<MarketStructureCategory>('industry');
const shortTermEmotion = ref<OverviewModuleState<ShortTermEmotionResponse>>(createState());
const shortTermConclusion = ref<OverviewModuleState<ShortTermConclusionResponse>>(createState());
const analysisHorizon = ref(readPreference('a-share-analysis:overview-horizon', '综合'));
@@ -65,6 +78,9 @@ export function useMarketOverview(
breadth.value,
sectors.value,
funds.value,
sectorRankings.value,
capitalFlow.value,
turnoverTrend.value,
shortTermEmotion.value,
shortTermConclusion.value,
].some((module) => module.status === 'loading'),
@@ -76,6 +92,9 @@ export function useMarketOverview(
breadth.value.status = 'loading';
sectors.value.status = 'loading';
funds.value.status = 'loading';
sectorRankings.value.status = 'loading';
capitalFlow.value.status = 'loading';
turnoverTrend.value.status = 'loading';
shortTermEmotion.value.status = 'loading';
shortTermConclusion.value.status = 'loading';
}
@@ -93,6 +112,22 @@ export function useMarketOverview(
}
}
async function refreshSectorRankings(category = sectorCategory.value): Promise<void> {
sectorCategory.value = category;
sectorRankings.value.status = 'loading';
await loadModule(sectorRankings, () =>
api.getSectorRankings({ category, sort: 'rise', limit: 10 }),
);
}
async function refreshCapitalFlow(category = capitalFlowCategory.value): Promise<void> {
capitalFlowCategory.value = category;
capitalFlow.value.status = 'loading';
await loadModule(capitalFlow, () =>
api.getCapitalFlow({ category, sort: 'inflow', limit: 10 }),
);
}
async function refresh(): Promise<void> {
setLoading();
await Promise.all([
@@ -101,6 +136,9 @@ export function useMarketOverview(
loadModule(breadth, api.getBreadth),
loadModule(sectors, api.getSectors),
loadModule(funds, api.getFunds),
refreshSectorRankings(),
refreshCapitalFlow(),
loadModule(turnoverTrend, () => api.getTurnoverTrend({ interval: '5' })),
loadModule(shortTermEmotion, api.getShortTermEmotion),
loadModule(shortTermConclusion, api.getShortTermConclusion),
]);
@@ -122,12 +160,19 @@ export function useMarketOverview(
breadth,
sectors,
funds,
sectorRankings,
capitalFlow,
turnoverTrend,
shortTermEmotion,
shortTermConclusion,
sectorCategory,
capitalFlowCategory,
isLoading,
analysisHorizon,
analysisEngine,
refresh,
refreshSectorRankings,
refreshCapitalFlow,
setAnalysisHorizon,
setAnalysisEngine,
};