feat(FEAT-DS-001): 接入AKTools超短行情补充数据源
This commit is contained in:
@@ -29,7 +29,7 @@
|
|||||||
<!-- gitnexus:start -->
|
<!-- gitnexus:start -->
|
||||||
# GitNexus — Code Intelligence
|
# GitNexus — Code Intelligence
|
||||||
|
|
||||||
This project is indexed by GitNexus as **a-share-analysis** (3134 symbols, 4355 relationships, 85 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** (3435 symbols, 4895 relationships, 104 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||||
|
|
||||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,9 @@
|
|||||||
- 核心指数、三市场宽度、一级行业板块强弱和三市场成交额趋势。
|
- 核心指数、三市场宽度、一级行业板块强弱和三市场成交额趋势。
|
||||||
- 综合、短线、中长线视角入口,以及规则、AI、混合、对照分析偏好入口。
|
- 综合、短线、中长线视角入口,以及规则、AI、混合、对照分析偏好入口。
|
||||||
- 指数和板块详情入口已预留,详情页另立功能实现;强势个股模块尚未开发。
|
- 指数和板块详情入口已预留,详情页另立功能实现;强势个股模块尚未开发。
|
||||||
|
- 超短情绪补充模块已接入本机 AKTools:展示涨停、最高连板、炸板、昨日涨停表现、涨停行业和规则结论;AKTools 不可用时独立降级。
|
||||||
|
|
||||||
主力净流入、完整盘中新闻、版本化概念题材成分和 AI 自动结论仍需独立数据源或后续功能立项,不用 TDX 缺失字段冒充。
|
主力净流入、完整盘中新闻、版本化概念题材成分和 AI 自动结论仍需独立数据源或后续功能立项,不用 TDX 或 AKShare 缺失字段冒充。
|
||||||
|
|
||||||
## Projects
|
## Projects
|
||||||
|
|
||||||
@@ -34,6 +35,8 @@
|
|||||||
|
|
||||||
前端开发服务器固定监听 127.0.0.1:8888,API 使用 127.0.0.1:3000。当前首屏不依赖 Scheduler、Worker 或 RocketMQ 消费链路;MySQL、Redis 和 RocketMQ 是否启用,以各进程的 local 配置和具体功能需求为准。
|
前端开发服务器固定监听 127.0.0.1:8888,API 使用 127.0.0.1:3000。当前首屏不依赖 Scheduler、Worker 或 RocketMQ 消费链路;MySQL、Redis 和 RocketMQ 是否启用,以各进程的 local 配置和具体功能需求为准。
|
||||||
|
|
||||||
|
AKTools 是项目外部维护的 AKShare HTTP 服务,开发机默认监听 `127.0.0.1:8080`。API 仅在 `AKTOOLS_ENABLED=true` 时调用三个固定接口;项目不安装、启动、停止或暴露 AKTools,也不允许前端直接请求它。生产是否启用必须先完成上游授权、稳定性和访问控制评估。
|
||||||
|
|
||||||
数据库开发约束:Schema 基线只能读取开发库 a_share_analysis_dev,禁止触碰生产库;字符集为 utf8mb4,排序规则为 utf8mb4_general_ci,时区为东八区。
|
数据库开发约束:Schema 基线只能读取开发库 a_share_analysis_dev,禁止触碰生产库;字符集为 utf8mb4,排序规则为 utf8mb4_general_ci,时区为东八区。
|
||||||
|
|
||||||
## TDX 连接方式
|
## TDX 连接方式
|
||||||
|
|||||||
+4
@@ -6,6 +6,10 @@ API_PORT=3000
|
|||||||
LOG_LEVEL=debug
|
LOG_LEVEL=debug
|
||||||
TDX_BASE_URL=http://127.0.0.1:17710
|
TDX_BASE_URL=http://127.0.0.1:17710
|
||||||
TDX_TIMEOUT_MS=5000
|
TDX_TIMEOUT_MS=5000
|
||||||
|
# AKTools 仅在本机内网补充超短行情,关闭时短线模块返回不可用。
|
||||||
|
AKTOOLS_ENABLED=false
|
||||||
|
AKTOOLS_BASE_URL=http://127.0.0.1:8080
|
||||||
|
AKTOOLS_TIMEOUT_MS=10000
|
||||||
|
|
||||||
# 第一阶段实际启用的 Redis。
|
# 第一阶段实际启用的 Redis。
|
||||||
REDIS_HOST=127.0.0.1
|
REDIS_HOST=127.0.0.1
|
||||||
|
|||||||
+4
@@ -5,6 +5,10 @@ API_PORT=3000
|
|||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
TDX_BASE_URL=http://127.0.0.1:17710
|
TDX_BASE_URL=http://127.0.0.1:17710
|
||||||
TDX_TIMEOUT_MS=5000
|
TDX_TIMEOUT_MS=5000
|
||||||
|
# AKTools 仅允许受控内网回环部署,未配置时保持关闭。
|
||||||
|
AKTOOLS_ENABLED=false
|
||||||
|
AKTOOLS_BASE_URL=http://127.0.0.1:8080
|
||||||
|
AKTOOLS_TIMEOUT_MS=10000
|
||||||
|
|
||||||
REDIS_HOST=127.0.0.1
|
REDIS_HOST=127.0.0.1
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ tools/ 可重复执行的治理和开发工具
|
|||||||
- Redis保存最新快照、缓存、锁和限流状态。
|
- Redis保存最新快照、缓存、锁和限流状态。
|
||||||
- Vue前端只读后端 API,不直接接触外部数据源和密钥。
|
- Vue前端只读后端 API,不直接接触外部数据源和密钥。
|
||||||
|
|
||||||
|
市场概览的 AKTools 补充链路是按需读取的例外:Nest API 通过固定 Adapter 调用外部 AKTools HTTP 服务,前端不直连,且失败只影响超短模块。它不经过 Scheduler、Worker、RocketMQ、MySQL 或 Redis,也不改变后续正式采集链路的总体架构。
|
||||||
|
|
||||||
## 3. 前后端模块边界
|
## 3. 前后端模块边界
|
||||||
|
|
||||||
后端以业务模块为一级目录,模块内部按实际职责建立 Controller、Service、DTO、Domain、Repository 和 Adapter。小模块不强制凑齐目录,禁止把全部 Controller/Service 堆入全局目录,也禁止无业务逻辑的透传层。领域规则只依赖项目端口,MySQL、Redis、RocketMQ、免费数据源和 AI Provider 由基础设施 Adapter 实现。
|
后端以业务模块为一级目录,模块内部按实际职责建立 Controller、Service、DTO、Domain、Repository 和 Adapter。小模块不强制凑齐目录,禁止把全部 Controller/Service 堆入全局目录,也禁止无业务逻辑的透传层。领域规则只依赖项目端口,MySQL、Redis、RocketMQ、免费数据源和 AI Provider 由基础设施 Adapter 实现。
|
||||||
|
|||||||
@@ -31,6 +31,16 @@ pnpm --filter @a-share-analysis/web dev
|
|||||||
|
|
||||||
API 健康端点是 `http://127.0.0.1:3000/api/v1/health`,Web 是 `http://127.0.0.1:8888/`。Scheduler 与 Worker 在 GOV-009 中保持 `ROCKETMQ_ENABLED=false`;没有经过验证的 Node RocketMQ 客户端时,启用配置会直接失败,不使用内存队列伪降级。
|
API 健康端点是 `http://127.0.0.1:3000/api/v1/health`,Web 是 `http://127.0.0.1:8888/`。Scheduler 与 Worker 在 GOV-009 中保持 `ROCKETMQ_ENABLED=false`;没有经过验证的 Node RocketMQ 客户端时,启用配置会直接失败,不使用内存队列伪降级。
|
||||||
|
|
||||||
|
## AKTools 补充数据源
|
||||||
|
|
||||||
|
AKTools/AKShare 作为开发机外部服务单独运行,默认地址为 `http://127.0.0.1:8080`。启动 API 前确认服务可访问;需要启用短线情绪模块时,在 API local 配置中设置 `AKTOOLS_ENABLED=true`,否则模块明确显示不可用,不影响 TDX 模块。Nest 只允许三个已验证接口,前端不直连 AKTools。项目不会管理该 Python 服务的安装、进程和数据。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --fail 'http://127.0.0.1:8080/api/public/stock_zt_pool_em?date=20260824'
|
||||||
|
```
|
||||||
|
|
||||||
|
生产环境不能直接复用本机 AKTools 配置;启用前必须提供受控内网地址、认证/访问控制、上游授权和稳定性评估,并扩展服务端白名单校验。
|
||||||
|
|
||||||
## 日常验证与停止
|
## 日常验证与停止
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ curl --fail http://127.0.0.1/
|
|||||||
|
|
||||||
当前工程尚无业务表迁移;后续新增迁移后必须在切换 `current` 前单独执行并记录结果。生产环境禁止 `synchronize: true`,开发流程也禁止用生产数据库做 Schema 基线或连接测试。
|
当前工程尚无业务表迁移;后续新增迁移后必须在切换 `current` 前单独执行并记录结果。生产环境禁止 `synchronize: true`,开发流程也禁止用生产数据库做 Schema 基线或连接测试。
|
||||||
|
|
||||||
|
## AKTools 生产边界
|
||||||
|
|
||||||
|
AKTools 不随本项目发布,也不由 systemd 单元管理。生产 API 默认关闭 `AKTOOLS_ENABLED`;只有在 AKTools 以受控内网服务部署、完成访问控制、授权和稳定性评估后,才可在 API 的生产环境文件中启用。生产配置不得把 AKTools 地址暴露给浏览器,且必须同步修改服务端白名单和健康检查方案;未完成这些前置条件时保持短线模块 `unavailable`。
|
||||||
|
|
||||||
## 检查与回退
|
## 检查与回退
|
||||||
|
|
||||||
使用 `systemctl status`、`journalctl -u`、HTTP 健康端点和 Nginx 同源访问结果确认发布。任一验证失败时,把 `/opt/a-share-analysis/current` 原子切回上一完整发布目录,重启三个 systemd 服务并重复健康检查。
|
使用 `systemctl status`、`journalctl -u`、HTTP 健康端点和 Nginx 同源访问结果确认发布。任一验证失败时,把 `/opt/a-share-analysis/current` 原子切回上一完整发布目录,重启三个 systemd 服务并重复健康检查。
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# AKTools 超短行情补充数据源开发总结
|
# AKTools 超短行情补充数据源开发总结
|
||||||
|
|
||||||
- 功能编号:FEAT-DS-001
|
- 功能编号:FEAT-DS-001
|
||||||
- 状态:design_approved
|
- 状态:in_review
|
||||||
- 发布状态:unreleased
|
- 发布状态:unreleased
|
||||||
|
|
||||||
## 原始开发需求
|
## 原始开发需求
|
||||||
@@ -14,11 +14,11 @@ V1 采用 AKTools 外部服务直连,不新增 Python 服务和 Docker;暂
|
|||||||
|
|
||||||
## 实际完成需求
|
## 实际完成需求
|
||||||
|
|
||||||
开发完成后记录实际完成的客户端、接口、页面、配置和验证结果。
|
已完成 AKTools 配置校验、固定接口 Client、中文字段 Mapper、短线情绪与规则结论 DTO/Service/Controller、OpenAPI 生成契约、Vue Axios API、Composable 状态、短线情绪面板和开发/生产环境模板。页面通过独立状态展示正常、部分、延迟、不可用和空数据,不阻塞 TDX 模块。
|
||||||
|
|
||||||
## 修复缺陷
|
## 修复缺陷
|
||||||
|
|
||||||
开发完成后记录本功能发现并修复的缺陷;当前设计阶段没有代码缺陷修复。
|
修复了短线情绪面板缺失导致的前端测试导入失败,并补齐页面未消费短线结论响应的问题;同时修复页面接线遗漏造成的 `shortTermConclusion` 未定义告警。未发现 TDX 回归。
|
||||||
|
|
||||||
## 未完成与延期项
|
## 未完成与延期项
|
||||||
|
|
||||||
@@ -26,8 +26,8 @@ V1 采用 AKTools 外部服务直连,不新增 Python 服务和 Docker;暂
|
|||||||
|
|
||||||
## 关键提交
|
## 关键提交
|
||||||
|
|
||||||
开发完成并推送后记录中文提交和远端 SHA。
|
实现提交与文档收口提交均使用带 `FEAT-DS-001` 的中文 Conventional Commit;最终 SHA 与远端推送结果在发布前补录。
|
||||||
|
|
||||||
## 验证证据
|
## 验证证据
|
||||||
|
|
||||||
开发完成后记录 Schema、GitNexus、单元测试、契约测试、E2E、构建、视觉验证、安全和性能证据。
|
Schema 基线与校验通过(`v0.0.10 captured_empty`);开发前 GitNexus 风险 LOW,页面改动前对 `useMarketOverview` 影响分析为 LOW;后端 15 个测试文件 79 个测试通过,前端 8 个测试文件 23 个测试通过,Web typecheck 通过,浏览器桌面/390×844 视觉检查通过且 `scrollWidth=innerWidth`。安全边界包括回环地址白名单、固定接口、超时、响应体上限、JSON 校验和错误脱敏;三个上游请求并行,单请求超时 10 秒,当前性能证据为本机定向测试,不代表生产容量。
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
# GitNexus 前后对比
|
# GitNexus 前后对比
|
||||||
|
|
||||||
开发前基线已记录在 `before.md`。开发后需要对比新增 AKTools Adapter、短线 Service、Controller、Web API、配置和文档是否与计划一致,并说明任何额外影响。
|
开发前基线已记录在 `before.md`。开发后 GitNexus 重新索引确认新增 AKTools Adapter、短线 Service、Controller、Web API、配置、页面和文档均落在计划范围;现有 TDX 市场概览执行流未被改写。`useMarketOverview` 的直接消费者为页面和测试,影响风险 LOW;未发现数据库、Redis、RocketMQ、Scheduler 或 Worker 新执行流。
|
||||||
|
|||||||
@@ -2,19 +2,19 @@
|
|||||||
|
|
||||||
## 任务清单
|
## 任务清单
|
||||||
|
|
||||||
1. 完成功能档案、配置契约和 GitNexus 影响记录。
|
1. [x] 完成功能档案、配置契约和 GitNexus 影响记录。
|
||||||
2. 为 AKTools 客户端、字段类型和映射器编写失败测试。
|
2. [x] 为 AKTools 客户端、字段类型和映射器编写失败测试。
|
||||||
3. 实现固定接口、超时、响应校验和中文字段映射。
|
3. [x] 实现固定接口、超时、响应校验和中文字段映射。
|
||||||
4. 实现短线情绪 Service、规则结论、DTO 和 Controller。
|
4. [x] 实现短线情绪 Service、规则结论、DTO 和 Controller。
|
||||||
5. 更新 OpenAPI 生成契约、前端 API、页面模块和异常状态。
|
5. [x] 更新 OpenAPI 生成契约、前端 API、页面模块和异常状态。
|
||||||
6. 补充开发/生产模板及内网 AKTools 运维说明。
|
6. [x] 补充开发/生产模板及内网 AKTools 运维说明。
|
||||||
7. 执行单元、契约、E2E、构建、视觉验证、Review 和治理完成门禁。
|
7. [ ] 执行完整 `pnpm verify`、治理完成门禁和远程推送(收尾阶段)。
|
||||||
|
|
||||||
## 文件边界
|
## 文件边界
|
||||||
|
|
||||||
- 新增 `server/apps/api/src/market-overview/aktools/` 客户端、类型和映射。
|
- 新增 `server/apps/api/src/market-overview/aktools/` 客户端、类型和映射。
|
||||||
- 新增短线业务 DTO、Service 和 Controller;不改动既有 TDX Mapper。
|
- 新增短线业务 DTO、Service 和 Controller;不改动既有 TDX Mapper。
|
||||||
- 新增 `web/src/api/market-overview/short-term.ts` 或保持业务 API 目录内的等价稳定文件,页面只消费公开 DTO。
|
- 修改 `web/src/api/market-overview/index.ts`,页面只消费公开 DTO。
|
||||||
- 修改环境模板、架构文档、开发文档和 OpenAPI 生成产物。
|
- 修改环境模板、架构文档、开发文档和 OpenAPI 生成产物。
|
||||||
|
|
||||||
## 实施顺序
|
## 实施顺序
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
featureId: FEAT-DS-001
|
featureId: FEAT-DS-001
|
||||||
slug: aktools-supplement
|
slug: aktools-supplement
|
||||||
name: AKTools 超短行情补充数据源
|
name: AKTools 超短行情补充数据源
|
||||||
status: design_approved
|
status: in_review
|
||||||
release: unreleased
|
release: unreleased
|
||||||
source: aktools
|
source: aktools
|
||||||
scope: internal_lan_only
|
scope: internal_lan_only
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
## 当前状态
|
## 当前状态
|
||||||
|
|
||||||
设计阶段尚未执行本功能代码测试;已完成 AKTools 本机 HTTP 冒烟,涨停池、炸板池和昨日涨停池接口返回 HTTP 200,历史行情接口返回 HTTP 200。
|
已完成代码验证:后端 Vitest 15 个测试文件、79 个测试通过,包含 AKTools Client/Mapper、短线 Service、HTTP E2E 和 OpenAPI;前端 Vitest 8 个测试文件、23 个测试通过,包含短线面板、Composable 和 API;Web typecheck 通过。AKTools 本机涨停池、炸板池和昨日涨停池冒烟均返回 HTTP 200;`stock_zh_a_spot_em` 返回 HTTP 500,未纳入 V1。
|
||||||
|
|
||||||
## 安全验证
|
## 安全验证
|
||||||
|
|
||||||
设计阶段已确定回环绑定、固定接口、超时和脱敏边界;代码实现后补充自动化证据。
|
配置测试验证仅允许 `127.0.0.1:8080`,Client 测试覆盖禁用、超时、HTTP/协议错误、响应大小上限和脱敏异常;未向 Web 暴露 AKTools 地址或原始字段。
|
||||||
|
|
||||||
## 性能验证
|
## 性能验证
|
||||||
|
|
||||||
设计阶段已确定并行请求和 10 秒单请求超时目标;代码实现后补充耗时证据,不将单次本机结果视为容量结论。
|
Service 测试验证三个请求使用 `Promise.allSettled` 并行、单个失败返回 `partial`、全部失败返回 `unavailable`。单请求超时配置为 10 秒;当前仅有本机测试证据,不将其视为生产容量结论。
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 当前状态
|
## 当前状态
|
||||||
|
|
||||||
设计阶段尚未修改页面。实现后必须验证桌面和窄屏下短线情绪模块的加载、有效、部分和不可用状态。
|
已在 `http://127.0.0.1:8888/` 完成桌面默认视口和 `390×844` 窄屏检查。短线面板在 DOM 中可见,TDX 不可用时仍展示明确的 AKShare 空状态;窄屏 `document.documentElement.scrollWidth` 与 `window.innerWidth` 均为 390,无横向溢出。组件测试覆盖有效数据和 unavailable 警告,部分/延迟文案沿用既有面板样式并通过类型检查。
|
||||||
|
|
||||||
## 安全验证
|
## 安全验证
|
||||||
|
|
||||||
@@ -10,4 +10,4 @@
|
|||||||
|
|
||||||
## 性能验证
|
## 性能验证
|
||||||
|
|
||||||
记录页面首次可见状态和短线模块完成状态,确认 AKTools 延迟不会阻塞 TDX 核心模块。
|
首次可见状态先展示 TDX 和 AKShare 各自占位,不互相阻塞;短线 API 失败只改变短线模块状态。视觉检查未展示凭据、真实内网地址或原始异常载荷。
|
||||||
|
|||||||
@@ -2,4 +2,4 @@
|
|||||||
|
|
||||||
## 未发布
|
## 未发布
|
||||||
|
|
||||||
FEAT-DS-001 当前为未发布功能。计划接入 AKTools 内网补充数据源,提供超短情绪指标和规则结论;不改变 TDX 核心市场概览。
|
FEAT-DS-001 当前为未发布功能。已接入 AKTools 内网补充数据源,提供超短情绪指标和规则结论;不改变 TDX 核心市场概览。生产默认关闭,待授权、访问控制和稳定性评估后再决定启用。
|
||||||
|
|||||||
@@ -10,19 +10,19 @@
|
|||||||
|
|
||||||
## 发现
|
## 发现
|
||||||
|
|
||||||
当前尚未进入代码 Review;实现完成后记录按严重级别排序的问题和处置。
|
已完成实现 Review:未发现阻断项。Client 只允许三个固定 AKTools 路径,Service 用 `Promise.allSettled` 做模块级降级,DTO 不透传中文原始字段;Web 只通过 Axios API 文件访问 Nest。发现页面接线遗漏 `shortTermConclusion` 未解构,已在测试失败后修复。
|
||||||
|
|
||||||
## 测试缺口
|
## 测试缺口
|
||||||
|
|
||||||
需要覆盖上游 5xx、超时、部分接口失败、字段漂移、日期延迟和窄屏状态。
|
上游 5xx、超时、部分接口失败、字段非法、响应大小、不可用状态和窄屏布局已有定向覆盖;交易日期延迟口径和 AKShare 字段漂移仍需后续真实交易日联调。
|
||||||
|
|
||||||
## 安全与残余风险
|
## 安全与残余风险
|
||||||
|
|
||||||
检查固定内网地址、请求参数边界、错误脱敏、日志载荷和前端构建产物;AKShare 授权和上游稳定性是残余风险。
|
固定地址白名单、日期参数校验、固定接口、超时、响应体上限、JSON 结构校验和错误脱敏均已检查;未发现凭据或原始载荷进入前端。残余风险为 AKShare 授权、上游限流/字段变更和生产多机访问控制,故生产默认关闭。
|
||||||
|
|
||||||
## 性能与资源影响
|
## 性能与资源影响
|
||||||
|
|
||||||
检查并行请求、超时、响应大小和不引入缓存的边界;不得把本地单次测试宣传为生产容量证明。
|
三个请求并行,10 秒超时,2 MiB 响应上限,不引入缓存和消息链路;测试证据仅证明行为和边界,不构成生产容量证明。
|
||||||
|
|
||||||
## GitNexus 与同步修改
|
## GitNexus 与同步修改
|
||||||
|
|
||||||
@@ -34,4 +34,4 @@
|
|||||||
|
|
||||||
## 最终结论
|
## 最终结论
|
||||||
|
|
||||||
当前阶段:pending
|
最终结论:passed
|
||||||
|
|||||||
@@ -6,16 +6,16 @@
|
|||||||
|
|
||||||
## 不符合项与处置
|
## 不符合项与处置
|
||||||
|
|
||||||
当前没有代码不符合项;实现阶段若发现问题,必须在发布前修复并记录。
|
逐项检查通过:功能档案、中文注释、OpenAPI 生成契约、Axios API 边界、固定数据源 Adapter、模块级降级、环境模板和未提交凭据约束均符合规范。实现期间发现的页面接线遗漏已修复并由前端全量测试复验。
|
||||||
|
|
||||||
## 安全、性能与架构检查
|
## 安全、性能与架构检查
|
||||||
|
|
||||||
必须确认 AKTools 仅作为外部内网 Adapter,未新增 Docker、生产数据库连接、消息链路或前端直连;同时检查超时、响应上限和降级。
|
确认 AKTools 仅作为外部内网 Adapter,未新增 Docker、Python 服务、生产数据库连接、Redis/RocketMQ 消息链路或前端直连;超时、响应上限、并行请求和降级均有实现与测试证据。生产启用的授权、访问控制和容量评估仍是明确前置条件。
|
||||||
|
|
||||||
## GitNexus 与开发总结检查
|
## GitNexus 与开发总结检查
|
||||||
|
|
||||||
必须确认开发前后索引、影响计划、变更检测、开发总结、测试结果和 Review 记录完整。
|
开发前后 GitNexus 报告、影响计划、变更检测、开发总结、测试结果、视觉 QA、Review 和回退说明均已补齐;`governance:finish` 及最终提交前再次执行变更检测。
|
||||||
|
|
||||||
## 最终结论
|
## 最终结论
|
||||||
|
|
||||||
当前阶段:pending
|
最终结论:passed
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { createAktoolsClient, AktoolsClientError } from './aktools.client';
|
||||||
|
|
||||||
|
describe('AKTools HTTP 客户端', () => {
|
||||||
|
it('只请求固定涨停池路径并传递交易日', async () => {
|
||||||
|
const fetchImpl = vi.fn().mockResolvedValue(
|
||||||
|
new Response('[{"代码":"000001","连板数":2}]', {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const client = createAktoolsClient({
|
||||||
|
enabled: true,
|
||||||
|
baseUrl: 'http://127.0.0.1:8080',
|
||||||
|
timeoutMs: 1000,
|
||||||
|
fetchImpl,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(client.getLimitUpPool('20260824')).resolves.toEqual([
|
||||||
|
{ 代码: '000001', 连板数: 2 },
|
||||||
|
]);
|
||||||
|
expect(fetchImpl).toHaveBeenCalledWith(
|
||||||
|
'http://127.0.0.1:8080/api/public/stock_zt_pool_em?date=20260824',
|
||||||
|
expect.objectContaining({ method: 'GET' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('将 HTTP 错误转换为脱敏的客户端错误', async () => {
|
||||||
|
const fetchImpl = vi.fn().mockResolvedValue(new Response('secret upstream detail', { status: 500 }));
|
||||||
|
const client = createAktoolsClient({
|
||||||
|
enabled: true,
|
||||||
|
baseUrl: 'http://127.0.0.1:8080',
|
||||||
|
timeoutMs: 1000,
|
||||||
|
fetchImpl,
|
||||||
|
});
|
||||||
|
|
||||||
|
const promise = client.getBrokenBoardPool('20260824');
|
||||||
|
await expect(promise).rejects.toBeInstanceOf(AktoolsClientError);
|
||||||
|
await expect(promise).rejects.toMatchObject({
|
||||||
|
kind: 'unavailable',
|
||||||
|
message: 'AKTools 服务不可用',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('将请求超时转换为稳定错误', async () => {
|
||||||
|
const fetchImpl = vi.fn().mockRejectedValue(Object.assign(new Error('aborted'), { name: 'AbortError' }));
|
||||||
|
const client = createAktoolsClient({
|
||||||
|
enabled: true,
|
||||||
|
baseUrl: 'http://127.0.0.1:8080',
|
||||||
|
timeoutMs: 1000,
|
||||||
|
fetchImpl,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(client.getPreviousLimitUpPool('20260824')).rejects.toMatchObject({
|
||||||
|
kind: 'timeout',
|
||||||
|
message: 'AKTools 请求超时',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import type { AktoolsRuntimeConfig } from '../../../../../libs/config/src/runtime-config';
|
||||||
|
import type { AktoolsDataClient, AktoolsRawRow } from './aktools.types';
|
||||||
|
|
||||||
|
export type AktoolsClientErrorKind =
|
||||||
|
| 'disabled'
|
||||||
|
| 'timeout'
|
||||||
|
| 'network'
|
||||||
|
| 'protocol'
|
||||||
|
| 'unavailable';
|
||||||
|
|
||||||
|
export class AktoolsClientError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly kind: AktoolsClientErrorKind,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'AktoolsClientError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AktoolsClientOptions extends AktoolsRuntimeConfig {
|
||||||
|
fetchImpl?: typeof fetch;
|
||||||
|
maxResponseBytes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
|
||||||
|
const ENDPOINTS = Object.freeze({
|
||||||
|
limitUp: 'stock_zt_pool_em',
|
||||||
|
brokenBoard: 'stock_zt_pool_zbgc_em',
|
||||||
|
previousLimitUp: 'stock_zt_pool_previous_em',
|
||||||
|
});
|
||||||
|
|
||||||
|
function isAbortError(error: unknown): boolean {
|
||||||
|
return error instanceof Error && error.name === 'AbortError';
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertTradeDate(tradeDate: string): void {
|
||||||
|
if (!/^\d{8}$/.test(tradeDate)) throw new AktoolsClientError('protocol', '交易日期格式错误');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AKTools 只作为固定的内网补充源,禁止把通用函数名和 URL 暴露给请求方。
|
||||||
|
* 客户端在这里统一处理上游网络和协议边界,业务层只接收数组记录。
|
||||||
|
*/
|
||||||
|
export function createAktoolsClient({
|
||||||
|
enabled,
|
||||||
|
baseUrl,
|
||||||
|
timeoutMs,
|
||||||
|
fetchImpl = fetch,
|
||||||
|
maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES,
|
||||||
|
}: AktoolsClientOptions): AktoolsDataClient {
|
||||||
|
const endpoint = `${baseUrl.replace(/\/$/, '')}/api/public/`;
|
||||||
|
|
||||||
|
async function request(path: string, tradeDate: string): Promise<AktoolsRawRow[]> {
|
||||||
|
assertTradeDate(tradeDate);
|
||||||
|
if (!enabled) throw new AktoolsClientError('disabled', 'AKTools 未启用');
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
const response = await fetchImpl(
|
||||||
|
`${endpoint}${path}?date=${encodeURIComponent(tradeDate)}`,
|
||||||
|
{
|
||||||
|
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 AktoolsRawRow[];
|
||||||
|
} 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 {
|
||||||
|
getLimitUpPool: (tradeDate) => request(ENDPOINTS.limitUp, tradeDate),
|
||||||
|
getBrokenBoardPool: (tradeDate) => request(ENDPOINTS.brokenBoard, tradeDate),
|
||||||
|
getPreviousLimitUpPool: (tradeDate) => request(ENDPOINTS.previousLimitUp, tradeDate),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
mapBrokenBoardPool,
|
||||||
|
mapLimitUpPool,
|
||||||
|
mapPreviousLimitUpPool,
|
||||||
|
} from './aktools.mapper';
|
||||||
|
|
||||||
|
describe('AKTools 超短字段映射', () => {
|
||||||
|
it('过滤缺失连板数的记录并生成连板梯队', () => {
|
||||||
|
expect(
|
||||||
|
mapLimitUpPool([
|
||||||
|
{ 代码: '000001', 名称: '甲', 连板数: 3, 所属行业: '软件服务', 封板资金: 100000000 },
|
||||||
|
{ 代码: '000002', 名称: '乙', 连板数: 2, 所属行业: '软件服务', 封板资金: 50000000 },
|
||||||
|
{ 代码: '000003', 名称: '丙', 连板数: 'bad', 所属行业: '汽车', 封板资金: 1 },
|
||||||
|
]),
|
||||||
|
).toEqual({
|
||||||
|
count: 2,
|
||||||
|
maxBoard: 3,
|
||||||
|
tiers: [
|
||||||
|
{ board: 2, count: 1 },
|
||||||
|
{ board: 3, count: 1 },
|
||||||
|
],
|
||||||
|
industries: [
|
||||||
|
{ name: '软件服务', count: 2 },
|
||||||
|
],
|
||||||
|
sealFundsAmount: 1.5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('统计有效炸板数量并忽略空记录', () => {
|
||||||
|
expect(mapBrokenBoardPool([{ 代码: '000001' }, {}, null as never])).toEqual({ count: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('计算昨日涨停股涨跌分布和平均涨跌幅', () => {
|
||||||
|
expect(
|
||||||
|
mapPreviousLimitUpPool([
|
||||||
|
{ 代码: '000001', 涨跌幅: 2.5 },
|
||||||
|
{ 代码: '000002', 涨跌幅: -1 },
|
||||||
|
{ 代码: '000003', 涨跌幅: 0 },
|
||||||
|
{ 代码: '000004', 涨跌幅: 'bad' },
|
||||||
|
]),
|
||||||
|
).toEqual({
|
||||||
|
count: 3,
|
||||||
|
averageChangePercent: 0.5,
|
||||||
|
upCount: 1,
|
||||||
|
downCount: 1,
|
||||||
|
flatCount: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import type { AktoolsRawRow } from './aktools.types';
|
||||||
|
|
||||||
|
export interface LimitUpSummary {
|
||||||
|
count: number;
|
||||||
|
maxBoard: number;
|
||||||
|
tiers: Array<{ board: number; count: number }>;
|
||||||
|
industries: Array<{ name: string; count: number }>;
|
||||||
|
sealFundsAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrokenBoardSummary {
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreviousLimitUpSummary {
|
||||||
|
count: number;
|
||||||
|
averageChangePercent: number;
|
||||||
|
upCount: number;
|
||||||
|
downCount: number;
|
||||||
|
flatCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPositiveInteger(value: unknown): number | null {
|
||||||
|
const parsed = toNumber(value);
|
||||||
|
return parsed !== null && Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasCode(row: unknown): row is AktoolsRawRow {
|
||||||
|
if (!row || typeof row !== 'object') return false;
|
||||||
|
const code = (row as Record<string, unknown>)['代码'];
|
||||||
|
return typeof code === 'string' && code.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function round(value: number): number {
|
||||||
|
return Math.round(value * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapLimitUpPool(rows: readonly AktoolsRawRow[]): LimitUpSummary {
|
||||||
|
const validRows = rows.filter((row) => hasCode(row) && toPositiveInteger(row['连板数']) !== null);
|
||||||
|
const tierCounts = new Map<number, number>();
|
||||||
|
const industryCounts = new Map<string, number>();
|
||||||
|
let sealFunds = 0;
|
||||||
|
|
||||||
|
for (const row of validRows) {
|
||||||
|
const board = toPositiveInteger(row['连板数']) as number;
|
||||||
|
tierCounts.set(board, (tierCounts.get(board) ?? 0) + 1);
|
||||||
|
const industry = typeof row['所属行业'] === 'string' ? row['所属行业'].trim() : '';
|
||||||
|
if (industry) industryCounts.set(industry, (industryCounts.get(industry) ?? 0) + 1);
|
||||||
|
const funds = toNumber(row['封板资金']);
|
||||||
|
if (funds !== null && funds >= 0) sealFunds += funds;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
count: validRows.length,
|
||||||
|
maxBoard: Math.max(0, ...tierCounts.keys()),
|
||||||
|
tiers: [...tierCounts.entries()]
|
||||||
|
.map(([board, count]) => ({ board, count }))
|
||||||
|
.sort((left, right) => left.board - right.board),
|
||||||
|
industries: [...industryCounts.entries()]
|
||||||
|
.map(([name, count]) => ({ name, count }))
|
||||||
|
.sort((left, right) => right.count - left.count || left.name.localeCompare(right.name)),
|
||||||
|
sealFundsAmount: round(sealFunds / 100_000_000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapBrokenBoardPool(rows: readonly unknown[]): BrokenBoardSummary {
|
||||||
|
return { count: rows.filter((row) => hasCode(row)).length };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapPreviousLimitUpPool(
|
||||||
|
rows: readonly AktoolsRawRow[],
|
||||||
|
): PreviousLimitUpSummary {
|
||||||
|
const changes = rows.flatMap((row) => {
|
||||||
|
if (!hasCode(row)) return [];
|
||||||
|
const change = toNumber(row['涨跌幅']);
|
||||||
|
return change === null ? [] : [change];
|
||||||
|
});
|
||||||
|
const upCount = changes.filter((value) => value > 0).length;
|
||||||
|
const downCount = changes.filter((value) => value < 0).length;
|
||||||
|
return {
|
||||||
|
count: changes.length,
|
||||||
|
averageChangePercent: changes.length
|
||||||
|
? round(changes.reduce((sum, value) => sum + value, 0) / changes.length)
|
||||||
|
: 0,
|
||||||
|
upCount,
|
||||||
|
downCount,
|
||||||
|
flatCount: changes.length - upCount - downCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export type AktoolsRawRow = Readonly<Record<string, unknown>>;
|
||||||
|
|
||||||
|
export interface AktoolsDataClient {
|
||||||
|
getLimitUpPool(tradeDate: string): Promise<AktoolsRawRow[]>;
|
||||||
|
getBrokenBoardPool(tradeDate: string): Promise<AktoolsRawRow[]>;
|
||||||
|
getPreviousLimitUpPool(tradeDate: string): Promise<AktoolsRawRow[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AktoolsShortTermSource {
|
||||||
|
source: 'akshare';
|
||||||
|
sourceUpdatedAt: string;
|
||||||
|
}
|
||||||
@@ -3,6 +3,9 @@ import { type DynamicModule, Module } from '@nestjs/common';
|
|||||||
import type { ApiRuntimeConfig } from '../../../../libs/config/src/runtime-config';
|
import type { ApiRuntimeConfig } from '../../../../libs/config/src/runtime-config';
|
||||||
import { MarketOverviewController } from './market-overview.controller';
|
import { MarketOverviewController } from './market-overview.controller';
|
||||||
import { MarketOverviewService, TDX_CLIENT_PORT } from './market-overview.service';
|
import { MarketOverviewService, TDX_CLIENT_PORT } from './market-overview.service';
|
||||||
|
import { ShortTermMarketController } from './short-term.controller';
|
||||||
|
import { AKTOOLS_CLIENT_PORT, ShortTermMarketService } from './short-term.service';
|
||||||
|
import { createAktoolsClient } from './aktools/aktools.client';
|
||||||
import { createTdxClient } from './tdx/tdx.client';
|
import { createTdxClient } from './tdx/tdx.client';
|
||||||
|
|
||||||
@Module({})
|
@Module({})
|
||||||
@@ -11,10 +14,12 @@ export class MarketOverviewModule {
|
|||||||
static register(config: ApiRuntimeConfig): DynamicModule {
|
static register(config: ApiRuntimeConfig): DynamicModule {
|
||||||
return {
|
return {
|
||||||
module: MarketOverviewModule,
|
module: MarketOverviewModule,
|
||||||
controllers: [MarketOverviewController],
|
controllers: [MarketOverviewController, ShortTermMarketController],
|
||||||
providers: [
|
providers: [
|
||||||
MarketOverviewService,
|
MarketOverviewService,
|
||||||
{ provide: TDX_CLIENT_PORT, useValue: createTdxClient(config.tdx) },
|
{ provide: TDX_CLIENT_PORT, useValue: createTdxClient(config.tdx) },
|
||||||
|
ShortTermMarketService,
|
||||||
|
{ provide: AKTOOLS_CLIENT_PORT, useValue: createAktoolsClient(config.aktools) },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Controller, Get, Inject } from '@nestjs/common';
|
||||||
|
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ShortTermConclusionResponseDto,
|
||||||
|
ShortTermEmotionResponseDto,
|
||||||
|
} from './short-term.dto';
|
||||||
|
import { ShortTermMarketService } from './short-term.service';
|
||||||
|
|
||||||
|
@ApiTags('market-overview-short-term')
|
||||||
|
@Controller('market-overview/short-term')
|
||||||
|
export class ShortTermMarketController {
|
||||||
|
constructor(
|
||||||
|
@Inject(ShortTermMarketService) private readonly service: ShortTermMarketService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('emotion')
|
||||||
|
@ApiOkResponse({ type: ShortTermEmotionResponseDto })
|
||||||
|
getEmotion(): Promise<ShortTermEmotionResponseDto> {
|
||||||
|
return this.service.getEmotion();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('conclusion')
|
||||||
|
@ApiOkResponse({ type: ShortTermConclusionResponseDto })
|
||||||
|
getConclusion(): Promise<ShortTermConclusionResponseDto> {
|
||||||
|
return this.service.getConclusion();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export type ShortTermStatus = 'fresh' | 'delayed' | 'partial' | 'unavailable';
|
||||||
|
|
||||||
|
export class ShortTermTierDto {
|
||||||
|
@ApiProperty({ type: Number })
|
||||||
|
board!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: Number })
|
||||||
|
count!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ShortTermIndustryDto {
|
||||||
|
@ApiProperty({ type: String })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: Number })
|
||||||
|
count!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ShortTermEmotionDataDto {
|
||||||
|
@ApiProperty({ type: String, example: '20260824' })
|
||||||
|
tradeDate!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||||
|
limitUpCount!: number | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||||
|
maxBoard!: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [ShortTermTierDto] })
|
||||||
|
tiers!: ShortTermTierDto[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||||
|
brokenBoardCount!: number | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||||
|
previousAverageChangePercent!: number | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||||
|
previousUpCount!: number | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||||
|
previousDownCount!: number | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||||
|
previousFlatCount!: number | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: Number, nullable: true, description: '封板资金,亿元' })
|
||||||
|
sealFundsAmount!: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [ShortTermIndustryDto] })
|
||||||
|
industries!: ShortTermIndustryDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ShortTermConclusionDataDto {
|
||||||
|
@ApiProperty({ type: String })
|
||||||
|
verdict!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [String] })
|
||||||
|
drivers!: string[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: [String] })
|
||||||
|
risks!: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ShortTermEmotionResponseDto {
|
||||||
|
@ApiProperty({ type: String })
|
||||||
|
snapshotId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, format: 'date-time' })
|
||||||
|
asOf!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, enum: ['akshare'] })
|
||||||
|
source!: 'akshare';
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, example: '20260824' })
|
||||||
|
tradeDate!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ['fresh', 'delayed', 'partial', 'unavailable'] })
|
||||||
|
status!: ShortTermStatus;
|
||||||
|
|
||||||
|
@ApiProperty({ type: () => ShortTermEmotionDataDto, nullable: true })
|
||||||
|
data!: ShortTermEmotionDataDto | null;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [String] })
|
||||||
|
warnings!: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ShortTermConclusionResponseDto {
|
||||||
|
@ApiProperty({ type: String })
|
||||||
|
snapshotId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, format: 'date-time' })
|
||||||
|
asOf!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, enum: ['akshare'] })
|
||||||
|
source!: 'akshare';
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, example: '20260824' })
|
||||||
|
tradeDate!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ['fresh', 'delayed', 'partial', 'unavailable'] })
|
||||||
|
status!: ShortTermStatus;
|
||||||
|
|
||||||
|
@ApiProperty({ type: () => ShortTermConclusionDataDto, nullable: true })
|
||||||
|
data!: ShortTermConclusionDataDto | null;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [String] })
|
||||||
|
warnings!: string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { ShortTermMarketService } from './short-term.service';
|
||||||
|
|
||||||
|
describe('短线行情服务', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-08-24T03:00:00.000Z'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('组合涨停、炸板和昨日涨停数据并返回新鲜情绪快照', async () => {
|
||||||
|
const client = {
|
||||||
|
getLimitUpPool: vi.fn().mockResolvedValue([
|
||||||
|
{ 代码: '000001', 连板数: 4, 所属行业: '软件服务', 封板资金: 100_000_000 },
|
||||||
|
]),
|
||||||
|
getBrokenBoardPool: vi.fn().mockResolvedValue([{ 代码: '000002' }]),
|
||||||
|
getPreviousLimitUpPool: vi.fn().mockResolvedValue([{ 代码: '000003', 涨跌幅: 2 }]),
|
||||||
|
};
|
||||||
|
const service = new ShortTermMarketService(client);
|
||||||
|
|
||||||
|
const response = await service.getEmotion();
|
||||||
|
|
||||||
|
expect(response).toMatchObject({
|
||||||
|
source: 'akshare',
|
||||||
|
status: 'fresh',
|
||||||
|
data: {
|
||||||
|
tradeDate: '20260824',
|
||||||
|
limitUpCount: 1,
|
||||||
|
maxBoard: 4,
|
||||||
|
brokenBoardCount: 1,
|
||||||
|
previousAverageChangePercent: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(client.getLimitUpPool).toHaveBeenCalledWith('20260824');
|
||||||
|
expect(client.getBrokenBoardPool).toHaveBeenCalledWith('20260824');
|
||||||
|
expect(client.getPreviousLimitUpPool).toHaveBeenCalledWith('20260824');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('单个 AKTools 模块失败时保留其他结果并标记 partial', async () => {
|
||||||
|
const client = {
|
||||||
|
getLimitUpPool: vi.fn().mockResolvedValue([{ 代码: '000001', 连板数: 2 }]),
|
||||||
|
getBrokenBoardPool: vi.fn().mockRejectedValue(new Error('上游详情不应透传')),
|
||||||
|
getPreviousLimitUpPool: vi.fn().mockResolvedValue([{ 代码: '000003', 涨跌幅: -1 }]),
|
||||||
|
};
|
||||||
|
const service = new ShortTermMarketService(client);
|
||||||
|
|
||||||
|
const response = await service.getEmotion();
|
||||||
|
|
||||||
|
expect(response.status).toBe('partial');
|
||||||
|
expect(response.data).toMatchObject({ limitUpCount: 1, previousAverageChangePercent: -1 });
|
||||||
|
expect(response.warnings.join(' ')).toContain('炸板池');
|
||||||
|
expect(response.warnings.join(' ')).not.toContain('上游详情');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('根据结构化指标生成保守的偏强结论', async () => {
|
||||||
|
const client = {
|
||||||
|
getLimitUpPool: vi.fn().mockResolvedValue([{ 代码: '000001', 连板数: 4 }]),
|
||||||
|
getBrokenBoardPool: vi.fn().mockResolvedValue([]),
|
||||||
|
getPreviousLimitUpPool: vi.fn().mockResolvedValue([{ 代码: '000003', 涨跌幅: 2 }]),
|
||||||
|
};
|
||||||
|
const service = new ShortTermMarketService(client);
|
||||||
|
|
||||||
|
const response = await service.getConclusion();
|
||||||
|
|
||||||
|
expect(response).toMatchObject({
|
||||||
|
source: 'akshare',
|
||||||
|
status: 'fresh',
|
||||||
|
data: { verdict: '超短情绪偏强' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import {
|
||||||
|
mapBrokenBoardPool,
|
||||||
|
mapLimitUpPool,
|
||||||
|
mapPreviousLimitUpPool,
|
||||||
|
type BrokenBoardSummary,
|
||||||
|
type LimitUpSummary,
|
||||||
|
type PreviousLimitUpSummary,
|
||||||
|
} from './aktools/aktools.mapper';
|
||||||
|
import { AktoolsClientError } from './aktools/aktools.client';
|
||||||
|
import type { AktoolsDataClient, AktoolsRawRow } from './aktools/aktools.types';
|
||||||
|
import {
|
||||||
|
type ShortTermConclusionDataDto,
|
||||||
|
type ShortTermConclusionResponseDto,
|
||||||
|
type ShortTermEmotionDataDto,
|
||||||
|
type ShortTermEmotionResponseDto,
|
||||||
|
} from './short-term.dto';
|
||||||
|
|
||||||
|
export const AKTOOLS_CLIENT_PORT = Symbol('AKTOOLS_CLIENT_PORT');
|
||||||
|
|
||||||
|
function getShanghaiDate(date: Date): string {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: 'Asia/Shanghai',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
}).formatToParts(date);
|
||||||
|
const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
|
||||||
|
return `${values.year}${values.month}${values.day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMeta(tradeDate: string) {
|
||||||
|
const asOf = new Date().toISOString();
|
||||||
|
return {
|
||||||
|
snapshotId: `${asOf.slice(0, 10).replaceAll('-', '')}-${asOf.slice(11, 19).replaceAll(':', '')}`,
|
||||||
|
asOf,
|
||||||
|
source: 'akshare' as const,
|
||||||
|
tradeDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function warningFor(error: unknown, subject: string): string {
|
||||||
|
if (error instanceof AktoolsClientError && error.kind === 'timeout') {
|
||||||
|
return `${subject}请求超时`;
|
||||||
|
}
|
||||||
|
if (error instanceof AktoolsClientError && error.kind === 'disabled') {
|
||||||
|
return 'AKTools 未启用';
|
||||||
|
}
|
||||||
|
return `${subject}数据不可用`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toEmotionData(
|
||||||
|
tradeDate: string,
|
||||||
|
limitUp: LimitUpSummary | null,
|
||||||
|
brokenBoard: BrokenBoardSummary | null,
|
||||||
|
previousLimitUp: PreviousLimitUpSummary | null,
|
||||||
|
): ShortTermEmotionDataDto {
|
||||||
|
return {
|
||||||
|
tradeDate,
|
||||||
|
limitUpCount: limitUp?.count ?? null,
|
||||||
|
maxBoard: limitUp?.maxBoard ?? null,
|
||||||
|
tiers: limitUp?.tiers ?? [],
|
||||||
|
brokenBoardCount: brokenBoard?.count ?? null,
|
||||||
|
previousAverageChangePercent: previousLimitUp?.averageChangePercent ?? null,
|
||||||
|
previousUpCount: previousLimitUp?.upCount ?? null,
|
||||||
|
previousDownCount: previousLimitUp?.downCount ?? null,
|
||||||
|
previousFlatCount: previousLimitUp?.flatCount ?? null,
|
||||||
|
sealFundsAmount: limitUp?.sealFundsAmount ?? null,
|
||||||
|
industries: limitUp?.industries ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createConclusion(data: ShortTermEmotionDataDto): ShortTermConclusionDataDto {
|
||||||
|
const strong =
|
||||||
|
(data.maxBoard ?? 0) >= 4 &&
|
||||||
|
(data.previousAverageChangePercent ?? Number.NEGATIVE_INFINITY) > 0 &&
|
||||||
|
(data.brokenBoardCount ?? 0) <= (data.limitUpCount ?? 0);
|
||||||
|
const weak =
|
||||||
|
(data.previousAverageChangePercent ?? 0) < 0 ||
|
||||||
|
(data.brokenBoardCount ?? 0) > (data.limitUpCount ?? 0);
|
||||||
|
const verdict = strong ? '超短情绪偏强' : weak ? '超短情绪偏弱' : '超短情绪分化';
|
||||||
|
return {
|
||||||
|
verdict,
|
||||||
|
drivers: [
|
||||||
|
data.limitUpCount === null ? '涨停池数据缺失' : `涨停 ${data.limitUpCount} 家`,
|
||||||
|
data.maxBoard === null ? '最高连板数据缺失' : `最高连板 ${data.maxBoard} 板`,
|
||||||
|
data.previousAverageChangePercent === null
|
||||||
|
? '昨日涨停表现缺失'
|
||||||
|
: `昨日涨停股平均涨跌幅 ${data.previousAverageChangePercent}%`,
|
||||||
|
],
|
||||||
|
risks: [
|
||||||
|
data.brokenBoardCount === null ? '炸板池数据缺失' : `炸板 ${data.brokenBoardCount} 家`,
|
||||||
|
'结论仅为规则统计,不构成交易建议',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ShortTermMarketService {
|
||||||
|
constructor(
|
||||||
|
@Inject(AKTOOLS_CLIENT_PORT) private readonly aktoolsClient: AktoolsDataClient,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getEmotion(): Promise<ShortTermEmotionResponseDto> {
|
||||||
|
const tradeDate = getShanghaiDate(new Date());
|
||||||
|
const meta = createMeta(tradeDate);
|
||||||
|
const [limitUp, brokenBoard, previousLimitUp] = await Promise.allSettled([
|
||||||
|
this.aktoolsClient.getLimitUpPool(tradeDate),
|
||||||
|
this.aktoolsClient.getBrokenBoardPool(tradeDate),
|
||||||
|
this.aktoolsClient.getPreviousLimitUpPool(tradeDate),
|
||||||
|
]);
|
||||||
|
const limitUpData = this.readResult(limitUp, mapLimitUpPool);
|
||||||
|
const brokenBoardData = this.readResult(brokenBoard, mapBrokenBoardPool);
|
||||||
|
const previousLimitUpData = this.readResult(previousLimitUp, mapPreviousLimitUpPool);
|
||||||
|
const failures = [
|
||||||
|
[limitUp, '涨停池'],
|
||||||
|
[brokenBoard, '炸板池'],
|
||||||
|
[previousLimitUp, '昨日涨停池'],
|
||||||
|
] as const;
|
||||||
|
const warnings = failures.flatMap(([result, subject]) =>
|
||||||
|
result.status === 'rejected' ? [warningFor(result.reason, subject)] : [],
|
||||||
|
);
|
||||||
|
const data =
|
||||||
|
limitUpData || brokenBoardData || previousLimitUpData
|
||||||
|
? toEmotionData(tradeDate, limitUpData, brokenBoardData, previousLimitUpData)
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
...meta,
|
||||||
|
status: data === null ? 'unavailable' : warnings.length ? 'partial' : 'fresh',
|
||||||
|
data,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getConclusion(): Promise<ShortTermConclusionResponseDto> {
|
||||||
|
const emotion = await this.getEmotion();
|
||||||
|
return {
|
||||||
|
...emotion,
|
||||||
|
data: emotion.data ? createConclusion(emotion.data) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private readResult<T>(
|
||||||
|
result: PromiseSettledResult<AktoolsRawRow[]>,
|
||||||
|
mapper: (rows: readonly AktoolsRawRow[]) => T,
|
||||||
|
): T | null {
|
||||||
|
return result.status === 'fulfilled' ? mapper(result.value) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type { INestApplication } from '@nestjs/common';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { ShortTermMarketController } from '../src/market-overview/short-term.controller';
|
||||||
|
import { AKTOOLS_CLIENT_PORT, ShortTermMarketService } from '../src/market-overview/short-term.service';
|
||||||
|
|
||||||
|
describe('短线行情 HTTP API', () => {
|
||||||
|
let app: INestApplication | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-08-24T03:00:00.000Z'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
try {
|
||||||
|
await app?.close();
|
||||||
|
} finally {
|
||||||
|
app = undefined;
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('返回结构化超短情绪指标和 AKShare 来源', async () => {
|
||||||
|
const client = {
|
||||||
|
getLimitUpPool: vi.fn().mockResolvedValue([{ 代码: '000001', 连板数: 4 }]),
|
||||||
|
getBrokenBoardPool: vi.fn().mockResolvedValue([{ 代码: '000002' }]),
|
||||||
|
getPreviousLimitUpPool: vi.fn().mockResolvedValue([{ 代码: '000003', 涨跌幅: 1.5 }]),
|
||||||
|
};
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
controllers: [ShortTermMarketController],
|
||||||
|
providers: [
|
||||||
|
ShortTermMarketService,
|
||||||
|
{ provide: AKTOOLS_CLIENT_PORT, useValue: client },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
app.setGlobalPrefix('api/v1');
|
||||||
|
await app.init();
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/market-overview/short-term/emotion')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body).toMatchObject({
|
||||||
|
source: 'akshare',
|
||||||
|
status: 'fresh',
|
||||||
|
tradeDate: '20260824',
|
||||||
|
data: { limitUpCount: 1, maxBoard: 4, brokenBoardCount: 1 },
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(response.body)).not.toContain('127.0.0.1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('全部 AKTools 数据不可用时返回 unavailable 而不是伪造空数据', async () => {
|
||||||
|
const client = {
|
||||||
|
getLimitUpPool: vi.fn().mockRejectedValue(new Error('private detail')),
|
||||||
|
getBrokenBoardPool: vi.fn().mockRejectedValue(new Error('private detail')),
|
||||||
|
getPreviousLimitUpPool: vi.fn().mockRejectedValue(new Error('private detail')),
|
||||||
|
};
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
controllers: [ShortTermMarketController],
|
||||||
|
providers: [
|
||||||
|
ShortTermMarketService,
|
||||||
|
{ provide: AKTOOLS_CLIENT_PORT, useValue: client },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
app.setGlobalPrefix('api/v1');
|
||||||
|
await app.init();
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/market-overview/short-term/conclusion')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body).toMatchObject({ source: 'akshare', status: 'unavailable', data: null });
|
||||||
|
expect(JSON.stringify(response.body)).not.toContain('private detail');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -36,6 +36,12 @@ const disabledAiEnv = {
|
|||||||
AI_TIMEOUT_MS: '30000',
|
AI_TIMEOUT_MS: '30000',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const enabledAktoolsEnv = {
|
||||||
|
AKTOOLS_ENABLED: 'true',
|
||||||
|
AKTOOLS_BASE_URL: 'http://127.0.0.1:8080',
|
||||||
|
AKTOOLS_TIMEOUT_MS: '10000',
|
||||||
|
};
|
||||||
|
|
||||||
const disabledRocketMqEnv = {
|
const disabledRocketMqEnv = {
|
||||||
ROCKETMQ_ENABLED: 'false',
|
ROCKETMQ_ENABLED: 'false',
|
||||||
ROCKETMQ_NAMESRV_ADDR: '127.0.0.1:9876',
|
ROCKETMQ_NAMESRV_ADDR: '127.0.0.1:9876',
|
||||||
@@ -44,6 +50,27 @@ const disabledRocketMqEnv = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('进程级运行配置', () => {
|
describe('进程级运行配置', () => {
|
||||||
|
it('API 接受仅限本机 8080 的 AKTools 配置', () => {
|
||||||
|
const config = loadApiConfig({
|
||||||
|
APP_ENV: 'development',
|
||||||
|
LOG_LEVEL: 'debug',
|
||||||
|
API_HOST: '127.0.0.1',
|
||||||
|
API_PORT: '3000',
|
||||||
|
TDX_BASE_URL: 'http://127.0.0.1:17710',
|
||||||
|
TDX_TIMEOUT_MS: '5000',
|
||||||
|
...baseRedisEnv,
|
||||||
|
...disabledMysqlEnv,
|
||||||
|
...disabledAiEnv,
|
||||||
|
...enabledAktoolsEnv,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(config.aktools).toEqual({
|
||||||
|
enabled: true,
|
||||||
|
baseUrl: 'http://127.0.0.1:8080',
|
||||||
|
timeoutMs: 10000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('API 只返回自己的配置且不要求 Scheduler 或 Worker 字段', () => {
|
it('API 只返回自己的配置且不要求 Scheduler 或 Worker 字段', () => {
|
||||||
const config = loadApiConfig({
|
const config = loadApiConfig({
|
||||||
APP_ENV: 'development',
|
APP_ENV: 'development',
|
||||||
|
|||||||
@@ -41,6 +41,13 @@ export interface TdxRuntimeConfig {
|
|||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** AKTools 仅作为本机内网补充源,启用时固定回环地址和端口。 */
|
||||||
|
export interface AktoolsRuntimeConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
baseUrl: string;
|
||||||
|
timeoutMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
/** RocketMQ 公共寻址配置;实际生产/消费参数由相应进程扩展。 */
|
/** RocketMQ 公共寻址配置;实际生产/消费参数由相应进程扩展。 */
|
||||||
export interface RocketMqRuntimeConfig {
|
export interface RocketMqRuntimeConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -61,6 +68,7 @@ export interface ApiRuntimeConfig extends CommonRuntimeConfig {
|
|||||||
processName: 'api';
|
processName: 'api';
|
||||||
api: Readonly<{ host: string; port: number }>;
|
api: Readonly<{ host: string; port: number }>;
|
||||||
tdx: Readonly<TdxRuntimeConfig>;
|
tdx: Readonly<TdxRuntimeConfig>;
|
||||||
|
aktools: Readonly<AktoolsRuntimeConfig>;
|
||||||
mysql: MysqlRuntimeConfig;
|
mysql: MysqlRuntimeConfig;
|
||||||
ai: AiRuntimeConfig;
|
ai: AiRuntimeConfig;
|
||||||
}
|
}
|
||||||
@@ -227,6 +235,35 @@ function loadTdxConfig(env: EnvironmentSource): TdxRuntimeConfig {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadAktoolsConfig(env: EnvironmentSource): AktoolsRuntimeConfig {
|
||||||
|
const enabled = env.AKTOOLS_ENABLED === undefined ? false : parseBoolean(env, 'AKTOOLS_ENABLED');
|
||||||
|
const baseUrl = optionalText(env, 'AKTOOLS_BASE_URL') || 'http://127.0.0.1:8080';
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(baseUrl);
|
||||||
|
} catch {
|
||||||
|
throw new Error('AKTOOLS_BASE_URL 必须为有效 URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.protocol !== 'http:') throw new Error('AKTOOLS_BASE_URL 必须使用 http 协议');
|
||||||
|
if (parsed.hostname !== '127.0.0.1') {
|
||||||
|
throw new Error('AKTOOLS_BASE_URL 必须指向本机回环地址');
|
||||||
|
}
|
||||||
|
if (parsed.port !== '8080') throw new Error('AKTOOLS_BASE_URL 必须使用 8080 端口');
|
||||||
|
if (parsed.pathname !== '' && parsed.pathname !== '/') {
|
||||||
|
throw new Error('AKTOOLS_BASE_URL 不得包含额外路径');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
baseUrl: parsed.toString().replace(/\/$/, ''),
|
||||||
|
timeoutMs:
|
||||||
|
env.AKTOOLS_TIMEOUT_MS === undefined
|
||||||
|
? 10_000
|
||||||
|
: parseInteger(env, 'AKTOOLS_TIMEOUT_MS', { min: 100, max: 30_000 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function loadRocketMqConfig(env: EnvironmentSource): RocketMqRuntimeConfig {
|
function loadRocketMqConfig(env: EnvironmentSource): RocketMqRuntimeConfig {
|
||||||
const enabled = parseBoolean(env, 'ROCKETMQ_ENABLED');
|
const enabled = parseBoolean(env, 'ROCKETMQ_ENABLED');
|
||||||
const nameServerAddress = optionalText(env, 'ROCKETMQ_NAMESRV_ADDR');
|
const nameServerAddress = optionalText(env, 'ROCKETMQ_NAMESRV_ADDR');
|
||||||
@@ -257,6 +294,7 @@ export function loadApiConfig(env: EnvironmentSource): ApiRuntimeConfig {
|
|||||||
port: parseInteger(env, 'API_PORT', { min: 1, max: 65_535 }),
|
port: parseInteger(env, 'API_PORT', { min: 1, max: 65_535 }),
|
||||||
},
|
},
|
||||||
tdx: loadTdxConfig(env),
|
tdx: loadTdxConfig(env),
|
||||||
|
aktools: loadAktoolsConfig(env),
|
||||||
redis: loadRedisConfig(env),
|
redis: loadRedisConfig(env),
|
||||||
mysql: loadMysqlConfig(env),
|
mysql: loadMysqlConfig(env),
|
||||||
ai: loadAiConfig(env),
|
ai: loadAiConfig(env),
|
||||||
@@ -335,6 +373,7 @@ export function toSafeRuntimeSummary(config: RuntimeConfig): Readonly<Record<str
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (config.processName === 'api') summary.api = config.api;
|
if (config.processName === 'api') summary.api = config.api;
|
||||||
|
if (config.processName === 'api') summary.aktools = { enabled: config.aktools.enabled };
|
||||||
if (config.processName === 'scheduler') summary.scheduler = config.scheduler;
|
if (config.processName === 'scheduler') summary.scheduler = config.scheduler;
|
||||||
if (config.processName === 'worker') summary.marketData = { mode: config.marketData.mode };
|
if (config.processName === 'worker') summary.marketData = { mode: config.marketData.mode };
|
||||||
|
|
||||||
|
|||||||
@@ -182,6 +182,101 @@
|
|||||||
"required": ["snapshotId", "asOf", "source", "status", "warnings", "data"],
|
"required": ["snapshotId", "asOf", "source", "status", "warnings", "data"],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"ShortTermConclusionDataDto": {
|
||||||
|
"properties": {
|
||||||
|
"drivers": { "items": { "type": "string" }, "type": "array" },
|
||||||
|
"risks": { "items": { "type": "string" }, "type": "array" },
|
||||||
|
"verdict": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["verdict", "drivers", "risks"],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"ShortTermConclusionResponseDto": {
|
||||||
|
"properties": {
|
||||||
|
"asOf": { "format": "date-time", "type": "string" },
|
||||||
|
"data": {
|
||||||
|
"allOf": [{ "$ref": "#/components/schemas/ShortTermConclusionDataDto" }],
|
||||||
|
"nullable": true,
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"snapshotId": { "type": "string" },
|
||||||
|
"source": { "enum": ["akshare"], "type": "string" },
|
||||||
|
"status": {
|
||||||
|
"enum": ["fresh", "delayed", "partial", "unavailable"],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"tradeDate": { "example": "20260824", "type": "string" },
|
||||||
|
"warnings": { "items": { "type": "string" }, "type": "array" }
|
||||||
|
},
|
||||||
|
"required": ["snapshotId", "asOf", "source", "tradeDate", "status", "data", "warnings"],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"ShortTermEmotionDataDto": {
|
||||||
|
"properties": {
|
||||||
|
"brokenBoardCount": { "nullable": true, "type": "number" },
|
||||||
|
"industries": {
|
||||||
|
"items": { "$ref": "#/components/schemas/ShortTermIndustryDto" },
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"limitUpCount": { "nullable": true, "type": "number" },
|
||||||
|
"maxBoard": { "nullable": true, "type": "number" },
|
||||||
|
"previousAverageChangePercent": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"previousDownCount": { "nullable": true, "type": "number" },
|
||||||
|
"previousFlatCount": { "nullable": true, "type": "number" },
|
||||||
|
"previousUpCount": { "nullable": true, "type": "number" },
|
||||||
|
"sealFundsAmount": {
|
||||||
|
"description": "封板资金,亿元",
|
||||||
|
"nullable": true,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"tiers": {
|
||||||
|
"items": { "$ref": "#/components/schemas/ShortTermTierDto" },
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"tradeDate": { "example": "20260824", "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["tradeDate", "tiers", "industries"],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"ShortTermEmotionResponseDto": {
|
||||||
|
"properties": {
|
||||||
|
"asOf": { "format": "date-time", "type": "string" },
|
||||||
|
"data": {
|
||||||
|
"allOf": [{ "$ref": "#/components/schemas/ShortTermEmotionDataDto" }],
|
||||||
|
"nullable": true,
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"snapshotId": { "type": "string" },
|
||||||
|
"source": { "enum": ["akshare"], "type": "string" },
|
||||||
|
"status": {
|
||||||
|
"enum": ["fresh", "delayed", "partial", "unavailable"],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"tradeDate": { "example": "20260824", "type": "string" },
|
||||||
|
"warnings": { "items": { "type": "string" }, "type": "array" }
|
||||||
|
},
|
||||||
|
"required": ["snapshotId", "asOf", "source", "tradeDate", "status", "data", "warnings"],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"ShortTermIndustryDto": {
|
||||||
|
"properties": {
|
||||||
|
"count": { "type": "number" },
|
||||||
|
"name": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["name", "count"],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"ShortTermTierDto": {
|
||||||
|
"properties": {
|
||||||
|
"board": { "type": "number" },
|
||||||
|
"count": { "type": "number" }
|
||||||
|
},
|
||||||
|
"required": ["board", "count"],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"SummaryDataDto": {
|
"SummaryDataDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"drivers": { "items": { "type": "string" }, "type": "array" },
|
"drivers": { "items": { "type": "string" }, "type": "array" },
|
||||||
@@ -323,6 +418,44 @@
|
|||||||
"tags": ["market-overview"]
|
"tags": ["market-overview"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/market-overview/short-term/conclusion": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "ShortTermMarketController_getConclusion",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ShortTermConclusionResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": ["market-overview-short-term"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/market-overview/short-term/emotion": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "ShortTermMarketController_getEmotion",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ShortTermEmotionResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": ["market-overview-short-term"]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/market-overview/summary": {
|
"/api/v1/market-overview/summary": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "MarketOverviewController_getSummary",
|
"operationId": "MarketOverviewController_getSummary",
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ describe('NestJS 三进程入口', () => {
|
|||||||
logLevel: 'debug',
|
logLevel: 'debug',
|
||||||
api: Object.freeze({ host: '127.0.0.1', port: 3000 }),
|
api: Object.freeze({ host: '127.0.0.1', port: 3000 }),
|
||||||
tdx: Object.freeze({ baseUrl: 'http://127.0.0.1:17710', timeoutMs: 5000 }),
|
tdx: Object.freeze({ baseUrl: 'http://127.0.0.1:17710', timeoutMs: 5000 }),
|
||||||
|
aktools: Object.freeze({ baseUrl: 'http://127.0.0.1:8080', enabled: false, timeoutMs: 10000 }),
|
||||||
redis: redisConfig,
|
redis: redisConfig,
|
||||||
mysql: mysqlConfig,
|
mysql: mysqlConfig,
|
||||||
ai: aiConfig,
|
ai: aiConfig,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { format } from 'prettier';
|
|||||||
|
|
||||||
import { HealthController } from '../apps/api/src/health.controller';
|
import { HealthController } from '../apps/api/src/health.controller';
|
||||||
import { MarketOverviewController } from '../apps/api/src/market-overview/market-overview.controller';
|
import { MarketOverviewController } from '../apps/api/src/market-overview/market-overview.controller';
|
||||||
|
import { ShortTermMarketController } from '../apps/api/src/market-overview/short-term.controller';
|
||||||
import {
|
import {
|
||||||
HealthService,
|
HealthService,
|
||||||
MYSQL_HEALTH_PORT,
|
MYSQL_HEALTH_PORT,
|
||||||
@@ -18,14 +19,28 @@ import {
|
|||||||
MarketOverviewService,
|
MarketOverviewService,
|
||||||
TDX_CLIENT_PORT,
|
TDX_CLIENT_PORT,
|
||||||
} from '../apps/api/src/market-overview/market-overview.service';
|
} from '../apps/api/src/market-overview/market-overview.service';
|
||||||
|
import { createAktoolsClient } from '../apps/api/src/market-overview/aktools/aktools.client';
|
||||||
|
import {
|
||||||
|
AKTOOLS_CLIENT_PORT,
|
||||||
|
ShortTermMarketService,
|
||||||
|
} from '../apps/api/src/market-overview/short-term.service';
|
||||||
import { DisabledHealthClient } from '../libs/infrastructure/src/disabled-health.client';
|
import { DisabledHealthClient } from '../libs/infrastructure/src/disabled-health.client';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [HealthController, MarketOverviewController],
|
controllers: [HealthController, MarketOverviewController, ShortTermMarketController],
|
||||||
providers: [
|
providers: [
|
||||||
HealthService,
|
HealthService,
|
||||||
MarketOverviewService,
|
MarketOverviewService,
|
||||||
{ provide: TDX_CLIENT_PORT, useValue: { request: async () => ({}) } },
|
{ provide: TDX_CLIENT_PORT, useValue: { request: async () => ({}) } },
|
||||||
|
ShortTermMarketService,
|
||||||
|
{
|
||||||
|
provide: AKTOOLS_CLIENT_PORT,
|
||||||
|
useValue: createAktoolsClient({
|
||||||
|
enabled: false,
|
||||||
|
baseUrl: 'http://127.0.0.1:8080',
|
||||||
|
timeoutMs: 10_000,
|
||||||
|
}),
|
||||||
|
},
|
||||||
{ provide: REDIS_HEALTH_PORT, useValue: new DisabledHealthClient('文档生成不连接 Redis') },
|
{ provide: REDIS_HEALTH_PORT, useValue: new DisabledHealthClient('文档生成不连接 Redis') },
|
||||||
{ provide: MYSQL_HEALTH_PORT, useValue: new DisabledHealthClient('文档生成不连接 MySQL') },
|
{ provide: MYSQL_HEALTH_PORT, useValue: new DisabledHealthClient('文档生成不连接 MySQL') },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -84,6 +84,38 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
'/api/v1/market-overview/short-term/conclusion': {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations['ShortTermMarketController_getConclusion'];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
'/api/v1/market-overview/short-term/emotion': {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations['ShortTermMarketController_getEmotion'];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
'/api/v1/market-overview/summary': {
|
'/api/v1/market-overview/summary': {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -207,6 +239,60 @@ export interface components {
|
|||||||
status: 'fresh' | 'delayed' | 'partial' | 'unavailable';
|
status: 'fresh' | 'delayed' | 'partial' | 'unavailable';
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
};
|
};
|
||||||
|
ShortTermConclusionDataDto: {
|
||||||
|
drivers: string[];
|
||||||
|
risks: string[];
|
||||||
|
verdict: string;
|
||||||
|
};
|
||||||
|
ShortTermConclusionResponseDto: {
|
||||||
|
/** Format: date-time */
|
||||||
|
asOf: string;
|
||||||
|
data: components['schemas']['ShortTermConclusionDataDto'] | null;
|
||||||
|
snapshotId: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
source: 'akshare';
|
||||||
|
/** @enum {string} */
|
||||||
|
status: 'fresh' | 'delayed' | 'partial' | 'unavailable';
|
||||||
|
/** @example 20260824 */
|
||||||
|
tradeDate: string;
|
||||||
|
warnings: string[];
|
||||||
|
};
|
||||||
|
ShortTermEmotionDataDto: {
|
||||||
|
brokenBoardCount?: number | null;
|
||||||
|
industries: components['schemas']['ShortTermIndustryDto'][];
|
||||||
|
limitUpCount?: number | null;
|
||||||
|
maxBoard?: number | null;
|
||||||
|
previousAverageChangePercent?: number | null;
|
||||||
|
previousDownCount?: number | null;
|
||||||
|
previousFlatCount?: number | null;
|
||||||
|
previousUpCount?: number | null;
|
||||||
|
/** @description 封板资金,亿元 */
|
||||||
|
sealFundsAmount?: number | null;
|
||||||
|
tiers: components['schemas']['ShortTermTierDto'][];
|
||||||
|
/** @example 20260824 */
|
||||||
|
tradeDate: string;
|
||||||
|
};
|
||||||
|
ShortTermEmotionResponseDto: {
|
||||||
|
/** Format: date-time */
|
||||||
|
asOf: string;
|
||||||
|
data: components['schemas']['ShortTermEmotionDataDto'] | null;
|
||||||
|
snapshotId: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
source: 'akshare';
|
||||||
|
/** @enum {string} */
|
||||||
|
status: 'fresh' | 'delayed' | 'partial' | 'unavailable';
|
||||||
|
/** @example 20260824 */
|
||||||
|
tradeDate: string;
|
||||||
|
warnings: string[];
|
||||||
|
};
|
||||||
|
ShortTermIndustryDto: {
|
||||||
|
count: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
ShortTermTierDto: {
|
||||||
|
board: number;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
SummaryDataDto: {
|
SummaryDataDto: {
|
||||||
drivers: string[];
|
drivers: string[];
|
||||||
marketStatus: string;
|
marketStatus: string;
|
||||||
@@ -338,6 +424,44 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
ShortTermMarketController_getConclusion: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['ShortTermConclusionResponseDto'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
ShortTermMarketController_getEmotion: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['ShortTermEmotionResponseDto'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
MarketOverviewController_getSummary: {
|
MarketOverviewController_getSummary: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ describe('市场概览业务 API', () => {
|
|||||||
await api.getBreadth();
|
await api.getBreadth();
|
||||||
await api.getSectors();
|
await api.getSectors();
|
||||||
await api.getFunds();
|
await api.getFunds();
|
||||||
|
await api.getShortTermEmotion();
|
||||||
|
await api.getShortTermConclusion();
|
||||||
|
|
||||||
expect(requestMock.mock.calls.map(([config]) => config.url)).toEqual([
|
expect(requestMock.mock.calls.map(([config]) => config.url)).toEqual([
|
||||||
'/api/v1/market-overview/summary',
|
'/api/v1/market-overview/summary',
|
||||||
@@ -24,6 +26,8 @@ describe('市场概览业务 API', () => {
|
|||||||
'/api/v1/market-overview/breadth',
|
'/api/v1/market-overview/breadth',
|
||||||
'/api/v1/market-overview/sectors',
|
'/api/v1/market-overview/sectors',
|
||||||
'/api/v1/market-overview/funds',
|
'/api/v1/market-overview/funds',
|
||||||
|
'/api/v1/market-overview/short-term/emotion',
|
||||||
|
'/api/v1/market-overview/short-term/conclusion',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import type {
|
|||||||
FundsResponse,
|
FundsResponse,
|
||||||
IndicesResponse,
|
IndicesResponse,
|
||||||
SectorsResponse,
|
SectorsResponse,
|
||||||
|
ShortTermConclusionResponse,
|
||||||
|
ShortTermEmotionResponse,
|
||||||
SummaryResponse,
|
SummaryResponse,
|
||||||
} from '@/types/market-overview';
|
} from '@/types/market-overview';
|
||||||
|
|
||||||
@@ -19,6 +21,16 @@ export function createMarketOverviewApi(requestClient: RequestClient = request)
|
|||||||
requestClient<SectorsResponse>({ method: 'get', url: '/api/v1/market-overview/sectors' }),
|
requestClient<SectorsResponse>({ method: 'get', url: '/api/v1/market-overview/sectors' }),
|
||||||
getFunds: () =>
|
getFunds: () =>
|
||||||
requestClient<FundsResponse>({ method: 'get', url: '/api/v1/market-overview/funds' }),
|
requestClient<FundsResponse>({ method: 'get', url: '/api/v1/market-overview/funds' }),
|
||||||
|
getShortTermEmotion: () =>
|
||||||
|
requestClient<ShortTermEmotionResponse>({
|
||||||
|
method: 'get',
|
||||||
|
url: '/api/v1/market-overview/short-term/emotion',
|
||||||
|
}),
|
||||||
|
getShortTermConclusion: () =>
|
||||||
|
requestClient<ShortTermConclusionResponse>({
|
||||||
|
method: 'get',
|
||||||
|
url: '/api/v1/market-overview/short-term/conclusion',
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -536,6 +536,86 @@ textarea {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.short-term-metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.short-term-metrics div {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
background: #f5f8f9;
|
||||||
|
border: 1px solid #e3e9ec;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.short-term-metrics span,
|
||||||
|
.short-term-secondary span,
|
||||||
|
.short-term-industries .column-label {
|
||||||
|
color: #7b8792;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.short-term-metrics strong {
|
||||||
|
color: #1c2a35;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.short-term-secondary {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 15px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid #e6eaee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.short-term-secondary strong {
|
||||||
|
color: #c33f3f;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.short-term-industries {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.short-term-industries .column-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 7px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.short-term-industries div {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.short-term-industries div span {
|
||||||
|
padding: 4px 7px;
|
||||||
|
color: #3f5e68;
|
||||||
|
background: #eef6f5;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.short-term-conclusion {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 11px;
|
||||||
|
border-top: 1px solid #e6eaee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.short-term-conclusion strong {
|
||||||
|
color: #167d82;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.overview-grid-top,
|
.overview-grid-top,
|
||||||
.overview-grid-bottom {
|
.overview-grid-bottom {
|
||||||
|
|||||||
@@ -5,10 +5,14 @@ export type IndicesResponse = components['schemas']['IndicesResponseDto'];
|
|||||||
export type BreadthResponse = components['schemas']['BreadthResponseDto'];
|
export type BreadthResponse = components['schemas']['BreadthResponseDto'];
|
||||||
export type SectorsResponse = components['schemas']['SectorsResponseDto'];
|
export type SectorsResponse = components['schemas']['SectorsResponseDto'];
|
||||||
export type FundsResponse = components['schemas']['FundsResponseDto'];
|
export type FundsResponse = components['schemas']['FundsResponseDto'];
|
||||||
|
export type ShortTermEmotionResponse = components['schemas']['ShortTermEmotionResponseDto'];
|
||||||
|
export type ShortTermConclusionResponse = components['schemas']['ShortTermConclusionResponseDto'];
|
||||||
export type SummaryData = NonNullable<SummaryResponse['data']>;
|
export type SummaryData = NonNullable<SummaryResponse['data']>;
|
||||||
export type IndicesData = NonNullable<IndicesResponse['data']>;
|
export type IndicesData = NonNullable<IndicesResponse['data']>;
|
||||||
export type BreadthData = NonNullable<BreadthResponse['data']>;
|
export type BreadthData = NonNullable<BreadthResponse['data']>;
|
||||||
export type SectorsData = NonNullable<SectorsResponse['data']>;
|
export type SectorsData = NonNullable<SectorsResponse['data']>;
|
||||||
export type FundsData = NonNullable<FundsResponse['data']>;
|
export type FundsData = NonNullable<FundsResponse['data']>;
|
||||||
|
export type ShortTermEmotionData = NonNullable<ShortTermEmotionResponse['data']>;
|
||||||
|
export type ShortTermConclusionData = NonNullable<ShortTermConclusionResponse['data']>;
|
||||||
|
|
||||||
export type OverviewModuleStatus = 'idle' | 'loading' | 'ready' | 'error';
|
export type OverviewModuleStatus = 'idle' | 'loading' | 'ready' | 'error';
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import MarketDrivers from './components/MarketDrivers.vue';
|
|||||||
import SectorOverview from './components/SectorOverview.vue';
|
import SectorOverview from './components/SectorOverview.vue';
|
||||||
import FundTrend from './components/FundTrend.vue';
|
import FundTrend from './components/FundTrend.vue';
|
||||||
import IndexOverview from './components/IndexOverview.vue';
|
import IndexOverview from './components/IndexOverview.vue';
|
||||||
|
import ShortTermEmotion from './components/ShortTermEmotion.vue';
|
||||||
import { useMarketOverview } from './useMarketOverview';
|
import { useMarketOverview } from './useMarketOverview';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -15,6 +16,8 @@ const {
|
|||||||
breadth,
|
breadth,
|
||||||
sectors,
|
sectors,
|
||||||
funds,
|
funds,
|
||||||
|
shortTermEmotion,
|
||||||
|
shortTermConclusion,
|
||||||
isLoading,
|
isLoading,
|
||||||
analysisHorizon,
|
analysisHorizon,
|
||||||
analysisEngine,
|
analysisEngine,
|
||||||
@@ -91,6 +94,11 @@ function openDetail(label: string) {
|
|||||||
@select="openDetail"
|
@select="openDetail"
|
||||||
/>
|
/>
|
||||||
<FundTrend :response="funds.response" :loading="funds.status === 'loading'" />
|
<FundTrend :response="funds.response" :loading="funds.status === 'loading'" />
|
||||||
|
<ShortTermEmotion
|
||||||
|
:response="shortTermEmotion.response"
|
||||||
|
:loading="shortTermEmotion.status === 'loading'"
|
||||||
|
:conclusion="shortTermConclusion.response"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import FundTrend from './FundTrend.vue';
|
import FundTrend from './FundTrend.vue';
|
||||||
import MarketBreadth from './MarketBreadth.vue';
|
import MarketBreadth from './MarketBreadth.vue';
|
||||||
import SectorOverview from './SectorOverview.vue';
|
import SectorOverview from './SectorOverview.vue';
|
||||||
|
import ShortTermEmotion from './ShortTermEmotion.vue';
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
snapshotId: '20260818-150000',
|
snapshotId: '20260818-150000',
|
||||||
@@ -152,4 +153,66 @@ describe('市场概览真实数据面板', () => {
|
|||||||
expect(wrapper.text()).toContain('2026-08-18');
|
expect(wrapper.text()).toContain('2026-08-18');
|
||||||
expect(wrapper.get('[data-direction="flat"]').attributes('data-direction')).toBe('flat');
|
expect(wrapper.get('[data-direction="flat"]').attributes('data-direction')).toBe('flat');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('短线情绪面板展示连板、炸板和昨日涨停表现', () => {
|
||||||
|
const wrapper = mount(ShortTermEmotion, {
|
||||||
|
props: {
|
||||||
|
loading: false,
|
||||||
|
response: {
|
||||||
|
...meta,
|
||||||
|
source: 'akshare' as const,
|
||||||
|
tradeDate: '20260818',
|
||||||
|
data: {
|
||||||
|
tradeDate: '20260818',
|
||||||
|
limitUpCount: 46,
|
||||||
|
maxBoard: 5,
|
||||||
|
tiers: [{ board: 5, count: 1 }],
|
||||||
|
brokenBoardCount: 16,
|
||||||
|
previousAverageChangePercent: 1.25,
|
||||||
|
previousUpCount: 30,
|
||||||
|
previousDownCount: 20,
|
||||||
|
previousFlatCount: 4,
|
||||||
|
sealFundsAmount: 12.34,
|
||||||
|
industries: [{ name: '软件服务', count: 8 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conclusion: {
|
||||||
|
...meta,
|
||||||
|
source: 'akshare' as const,
|
||||||
|
tradeDate: '20260818',
|
||||||
|
data: {
|
||||||
|
verdict: '超短情绪偏强',
|
||||||
|
drivers: ['涨停 46 家'],
|
||||||
|
risks: ['炸板 16 家'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('涨停 46');
|
||||||
|
expect(wrapper.text()).toContain('最高 5 板');
|
||||||
|
expect(wrapper.text()).toContain('炸板 16');
|
||||||
|
expect(wrapper.text()).toContain('昨日涨停平均 +1.25%');
|
||||||
|
expect(wrapper.text()).toContain('软件服务');
|
||||||
|
expect(wrapper.text()).toContain('超短情绪偏强');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('短线情绪模块不可用时显示来源状态和警告', () => {
|
||||||
|
const wrapper = mount(ShortTermEmotion, {
|
||||||
|
props: {
|
||||||
|
loading: false,
|
||||||
|
response: {
|
||||||
|
...meta,
|
||||||
|
source: 'akshare' as const,
|
||||||
|
tradeDate: '20260818',
|
||||||
|
status: 'unavailable' as const,
|
||||||
|
warnings: ['涨停池数据不可用'],
|
||||||
|
data: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('AKShare 数据暂不可用');
|
||||||
|
expect(wrapper.text()).toContain('涨停池数据不可用');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type {
|
||||||
|
ShortTermConclusionResponse,
|
||||||
|
ShortTermEmotionResponse,
|
||||||
|
} from '@/types/market-overview';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
response: ShortTermEmotionResponse | null;
|
||||||
|
loading: boolean;
|
||||||
|
conclusion?: ShortTermConclusionResponse | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function formatTradeDate(value: string | undefined): string {
|
||||||
|
return value?.length === 8 ? `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6)}` : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPercent(value: number | null | undefined): string {
|
||||||
|
if (value === null || value === undefined) return '—';
|
||||||
|
return `${value > 0 ? '+' : ''}${value.toFixed(2)}%`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="overview-panel short-term-emotion" aria-labelledby="short-term-emotion-title">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div>
|
||||||
|
<span class="section-kicker">超短情绪</span>
|
||||||
|
<h2 id="short-term-emotion-title">涨停与连板</h2>
|
||||||
|
</div>
|
||||||
|
<span class="panel-meta">{{ formatTradeDate(response?.data?.tradeDate) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="loading" class="module-placeholder">正在读取 AKShare 情绪数据…</div>
|
||||||
|
<div v-else-if="response?.status === 'unavailable'" class="module-unavailable">
|
||||||
|
<strong>AKShare 数据暂不可用</strong>
|
||||||
|
<span>{{ response.warnings[0] || '请确认 AKTools 服务已启动' }}</span>
|
||||||
|
</div>
|
||||||
|
<template v-else-if="response?.data">
|
||||||
|
<div v-if="response.status !== 'fresh'" class="module-warning">
|
||||||
|
<strong>{{ response.status === 'partial' ? '部分数据' : '数据延迟' }}</strong>
|
||||||
|
<span>{{ response.warnings[0] }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="short-term-metrics">
|
||||||
|
<div>
|
||||||
|
<span>涨停 {{ response.data.limitUpCount ?? '—' }}</span>
|
||||||
|
<strong aria-hidden="true"> {{ response.data.limitUpCount ?? '—' }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>最高 {{ response.data.maxBoard ?? '—' }} 板</span>
|
||||||
|
<strong aria-hidden="true"> {{ response.data.maxBoard ?? '—' }} 板</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>炸板 {{ response.data.brokenBoardCount ?? '—' }}</span>
|
||||||
|
<strong aria-hidden="true"> {{ response.data.brokenBoardCount ?? '—' }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="short-term-secondary">
|
||||||
|
<strong
|
||||||
|
>昨日涨停平均 {{ formatPercent(response.data.previousAverageChangePercent) }}</strong
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="short-term-industries">
|
||||||
|
<span class="column-label">涨停行业</span>
|
||||||
|
<div>
|
||||||
|
<span v-for="industry in response.data.industries.slice(0, 3)" :key="industry.name">
|
||||||
|
{{ industry.name }} {{ industry.count }}
|
||||||
|
</span>
|
||||||
|
<span v-if="!response.data.industries.length">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="conclusion?.data" class="short-term-conclusion">
|
||||||
|
<span class="column-label">规则结论</span>
|
||||||
|
<strong>{{ conclusion.data.verdict }}</strong>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="module-placeholder">暂无超短情绪数据</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -19,6 +19,8 @@ describe('市场概览状态', () => {
|
|||||||
getBreadth: vi.fn().mockResolvedValue(response),
|
getBreadth: vi.fn().mockResolvedValue(response),
|
||||||
getSectors: vi.fn().mockResolvedValue(response),
|
getSectors: vi.fn().mockResolvedValue(response),
|
||||||
getFunds: vi.fn().mockResolvedValue(response),
|
getFunds: vi.fn().mockResolvedValue(response),
|
||||||
|
getShortTermEmotion: vi.fn().mockResolvedValue(response),
|
||||||
|
getShortTermConclusion: vi.fn().mockResolvedValue(response),
|
||||||
};
|
};
|
||||||
const state = useMarketOverview(api);
|
const state = useMarketOverview(api);
|
||||||
|
|
||||||
@@ -28,6 +30,8 @@ describe('市场概览状态', () => {
|
|||||||
expect(state.indices.value.status).toBe('error');
|
expect(state.indices.value.status).toBe('error');
|
||||||
expect(state.breadth.value.status).toBe('ready');
|
expect(state.breadth.value.status).toBe('ready');
|
||||||
expect(state.isLoading.value).toBe(false);
|
expect(state.isLoading.value).toBe(false);
|
||||||
|
expect(state.shortTermEmotion.value.status).toBe('ready');
|
||||||
|
expect(state.shortTermConclusion.value.status).toBe('ready');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('分析偏好写入本地存储并可恢复', () => {
|
it('分析偏好写入本地存储并可恢复', () => {
|
||||||
|
|||||||
@@ -7,11 +7,19 @@ import type {
|
|||||||
IndicesResponse,
|
IndicesResponse,
|
||||||
OverviewModuleStatus,
|
OverviewModuleStatus,
|
||||||
SectorsResponse,
|
SectorsResponse,
|
||||||
|
ShortTermConclusionResponse,
|
||||||
|
ShortTermEmotionResponse,
|
||||||
SummaryResponse,
|
SummaryResponse,
|
||||||
} from '@/types/market-overview';
|
} from '@/types/market-overview';
|
||||||
|
|
||||||
type MarketResponse =
|
type MarketResponse =
|
||||||
SummaryResponse | IndicesResponse | BreadthResponse | SectorsResponse | FundsResponse;
|
| SummaryResponse
|
||||||
|
| IndicesResponse
|
||||||
|
| BreadthResponse
|
||||||
|
| SectorsResponse
|
||||||
|
| FundsResponse
|
||||||
|
| ShortTermEmotionResponse
|
||||||
|
| ShortTermConclusionResponse;
|
||||||
|
|
||||||
export interface OverviewModuleState<TResponse extends MarketResponse> {
|
export interface OverviewModuleState<TResponse extends MarketResponse> {
|
||||||
status: OverviewModuleStatus;
|
status: OverviewModuleStatus;
|
||||||
@@ -46,10 +54,20 @@ export function useMarketOverview(
|
|||||||
const breadth = ref<OverviewModuleState<BreadthResponse>>(createState());
|
const breadth = ref<OverviewModuleState<BreadthResponse>>(createState());
|
||||||
const sectors = ref<OverviewModuleState<SectorsResponse>>(createState());
|
const sectors = ref<OverviewModuleState<SectorsResponse>>(createState());
|
||||||
const funds = ref<OverviewModuleState<FundsResponse>>(createState());
|
const funds = ref<OverviewModuleState<FundsResponse>>(createState());
|
||||||
|
const shortTermEmotion = ref<OverviewModuleState<ShortTermEmotionResponse>>(createState());
|
||||||
|
const shortTermConclusion = ref<OverviewModuleState<ShortTermConclusionResponse>>(createState());
|
||||||
const analysisHorizon = ref(readPreference('a-share-analysis:overview-horizon', '综合'));
|
const analysisHorizon = ref(readPreference('a-share-analysis:overview-horizon', '综合'));
|
||||||
const analysisEngine = ref(readPreference('a-share-analysis:overview-engine', '规则'));
|
const analysisEngine = ref(readPreference('a-share-analysis:overview-engine', '规则'));
|
||||||
const isLoading = computed(() =>
|
const isLoading = computed(() =>
|
||||||
[summary.value, indices.value, breadth.value, sectors.value, funds.value].some(
|
[
|
||||||
|
summary.value,
|
||||||
|
indices.value,
|
||||||
|
breadth.value,
|
||||||
|
sectors.value,
|
||||||
|
funds.value,
|
||||||
|
shortTermEmotion.value,
|
||||||
|
shortTermConclusion.value,
|
||||||
|
].some(
|
||||||
(module) => module.status === 'loading',
|
(module) => module.status === 'loading',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -60,6 +78,8 @@ export function useMarketOverview(
|
|||||||
breadth.value.status = 'loading';
|
breadth.value.status = 'loading';
|
||||||
sectors.value.status = 'loading';
|
sectors.value.status = 'loading';
|
||||||
funds.value.status = 'loading';
|
funds.value.status = 'loading';
|
||||||
|
shortTermEmotion.value.status = 'loading';
|
||||||
|
shortTermConclusion.value.status = 'loading';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadModule<TResponse extends MarketResponse>(
|
async function loadModule<TResponse extends MarketResponse>(
|
||||||
@@ -83,6 +103,8 @@ export function useMarketOverview(
|
|||||||
loadModule(breadth, api.getBreadth),
|
loadModule(breadth, api.getBreadth),
|
||||||
loadModule(sectors, api.getSectors),
|
loadModule(sectors, api.getSectors),
|
||||||
loadModule(funds, api.getFunds),
|
loadModule(funds, api.getFunds),
|
||||||
|
loadModule(shortTermEmotion, api.getShortTermEmotion),
|
||||||
|
loadModule(shortTermConclusion, api.getShortTermConclusion),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +124,8 @@ export function useMarketOverview(
|
|||||||
breadth,
|
breadth,
|
||||||
sectors,
|
sectors,
|
||||||
funds,
|
funds,
|
||||||
|
shortTermEmotion,
|
||||||
|
shortTermConclusion,
|
||||||
isLoading,
|
isLoading,
|
||||||
analysisHorizon,
|
analysisHorizon,
|
||||||
analysisEngine,
|
analysisEngine,
|
||||||
|
|||||||
Reference in New Issue
Block a user