From 6e8a05114e3ce4c5a06ee6d70b8873f3dbe987cc Mon Sep 17 00:00:00 2001 From: Citrons <909229731@qq.com> Date: Mon, 24 Aug 2026 21:40:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(FEAT-DS-001):=20=E6=8E=A5=E5=85=A5AKTools?= =?UTF-8?q?=E8=B6=85=E7=9F=AD=E8=A1=8C=E6=83=85=E8=A1=A5=E5=85=85=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- README.md | 5 +- deploy/env/development/api.env.example | 4 + deploy/env/production/api.env.example | 4 + docs/architecture/system-architecture.md | 2 + docs/deployment/local-development.md | 10 ++ docs/deployment/ubuntu-source-production.md | 4 + .../development-summary.md | 10 +- .../gitnexus/comparison.md | 2 +- .../implementation-plan.md | 16 +- .../manifest.yaml | 2 +- .../qa/test-results.md | 6 +- .../qa/visual-qa.md | 4 +- .../release-notes.md | 2 +- .../review/code-review.md | 10 +- .../review/standards-review.md | 8 +- .../aktools/aktools.client.test.ts | 60 +++++++ .../market-overview/aktools/aktools.client.ts | 103 ++++++++++++ .../aktools/aktools.mapper.test.ts | 51 ++++++ .../market-overview/aktools/aktools.mapper.ts | 96 +++++++++++ .../market-overview/aktools/aktools.types.ts | 12 ++ .../market-overview/market-overview.module.ts | 7 +- .../market-overview/short-term.controller.ts | 28 ++++ .../api/src/market-overview/short-term.dto.ts | 111 +++++++++++++ .../short-term.service.test.ts | 75 +++++++++ .../src/market-overview/short-term.service.ts | 150 ++++++++++++++++++ server/apps/api/test/short-term.e2e-spec.ts | 80 ++++++++++ server/libs/config/src/runtime-config.test.ts | 27 ++++ server/libs/config/src/runtime-config.ts | 39 +++++ server/openapi/openapi.json | 133 ++++++++++++++++ server/test/process-smoke.test.ts | 1 + server/tools/export-openapi.ts | 17 +- web/src/api/generated/schema.ts | 124 +++++++++++++++ web/src/api/market-overview/index.test.ts | 4 + web/src/api/market-overview/index.ts | 12 ++ web/src/styles/index.scss | 80 ++++++++++ web/src/types/market-overview.ts | 4 + .../market-overview/MarketOverviewPage.vue | 8 + .../components/MarketDataPanels.test.ts | 63 ++++++++ .../components/ShortTermEmotion.vue | 77 +++++++++ .../market-overview/useMarketOverview.test.ts | 4 + .../market-overview/useMarketOverview.ts | 28 +++- 42 files changed, 1449 insertions(+), 36 deletions(-) create mode 100644 server/apps/api/src/market-overview/aktools/aktools.client.test.ts create mode 100644 server/apps/api/src/market-overview/aktools/aktools.client.ts create mode 100644 server/apps/api/src/market-overview/aktools/aktools.mapper.test.ts create mode 100644 server/apps/api/src/market-overview/aktools/aktools.mapper.ts create mode 100644 server/apps/api/src/market-overview/aktools/aktools.types.ts create mode 100644 server/apps/api/src/market-overview/short-term.controller.ts create mode 100644 server/apps/api/src/market-overview/short-term.dto.ts create mode 100644 server/apps/api/src/market-overview/short-term.service.test.ts create mode 100644 server/apps/api/src/market-overview/short-term.service.ts create mode 100644 server/apps/api/test/short-term.e2e-spec.ts create mode 100644 web/src/views/market-overview/components/ShortTermEmotion.vue diff --git a/AGENTS.md b/AGENTS.md index d1eb657..d30c8f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,7 @@ # 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. diff --git a/README.md b/README.md index 3b7472d..e2b937e 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,9 @@ - 核心指数、三市场宽度、一级行业板块强弱和三市场成交额趋势。 - 综合、短线、中长线视角入口,以及规则、AI、混合、对照分析偏好入口。 - 指数和板块详情入口已预留,详情页另立功能实现;强势个股模块尚未开发。 +- 超短情绪补充模块已接入本机 AKTools:展示涨停、最高连板、炸板、昨日涨停表现、涨停行业和规则结论;AKTools 不可用时独立降级。 -主力净流入、完整盘中新闻、版本化概念题材成分和 AI 自动结论仍需独立数据源或后续功能立项,不用 TDX 缺失字段冒充。 +主力净流入、完整盘中新闻、版本化概念题材成分和 AI 自动结论仍需独立数据源或后续功能立项,不用 TDX 或 AKShare 缺失字段冒充。 ## Projects @@ -34,6 +35,8 @@ 前端开发服务器固定监听 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,时区为东八区。 ## TDX 连接方式 diff --git a/deploy/env/development/api.env.example b/deploy/env/development/api.env.example index a446e98..89eae3b 100644 --- a/deploy/env/development/api.env.example +++ b/deploy/env/development/api.env.example @@ -6,6 +6,10 @@ API_PORT=3000 LOG_LEVEL=debug TDX_BASE_URL=http://127.0.0.1:17710 TDX_TIMEOUT_MS=5000 +# AKTools 仅在本机内网补充超短行情,关闭时短线模块返回不可用。 +AKTOOLS_ENABLED=false +AKTOOLS_BASE_URL=http://127.0.0.1:8080 +AKTOOLS_TIMEOUT_MS=10000 # 第一阶段实际启用的 Redis。 REDIS_HOST=127.0.0.1 diff --git a/deploy/env/production/api.env.example b/deploy/env/production/api.env.example index 19ee7c8..69c61db 100644 --- a/deploy/env/production/api.env.example +++ b/deploy/env/production/api.env.example @@ -5,6 +5,10 @@ API_PORT=3000 LOG_LEVEL=info TDX_BASE_URL=http://127.0.0.1:17710 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_PORT=6379 diff --git a/docs/architecture/system-architecture.md b/docs/architecture/system-architecture.md index 3cd789a..2f23a1b 100644 --- a/docs/architecture/system-architecture.md +++ b/docs/architecture/system-architecture.md @@ -40,6 +40,8 @@ tools/ 可重复执行的治理和开发工具 - Redis保存最新快照、缓存、锁和限流状态。 - Vue前端只读后端 API,不直接接触外部数据源和密钥。 +市场概览的 AKTools 补充链路是按需读取的例外:Nest API 通过固定 Adapter 调用外部 AKTools HTTP 服务,前端不直连,且失败只影响超短模块。它不经过 Scheduler、Worker、RocketMQ、MySQL 或 Redis,也不改变后续正式采集链路的总体架构。 + ## 3. 前后端模块边界 后端以业务模块为一级目录,模块内部按实际职责建立 Controller、Service、DTO、Domain、Repository 和 Adapter。小模块不强制凑齐目录,禁止把全部 Controller/Service 堆入全局目录,也禁止无业务逻辑的透传层。领域规则只依赖项目端口,MySQL、Redis、RocketMQ、免费数据源和 AI Provider 由基础设施 Adapter 实现。 diff --git a/docs/deployment/local-development.md b/docs/deployment/local-development.md index b810cc5..6fe648d 100644 --- a/docs/deployment/local-development.md +++ b/docs/deployment/local-development.md @@ -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 客户端时,启用配置会直接失败,不使用内存队列伪降级。 +## 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 diff --git a/docs/deployment/ubuntu-source-production.md b/docs/deployment/ubuntu-source-production.md index e8d68ca..900dc34 100644 --- a/docs/deployment/ubuntu-source-production.md +++ b/docs/deployment/ubuntu-source-production.md @@ -43,6 +43,10 @@ curl --fail http://127.0.0.1/ 当前工程尚无业务表迁移;后续新增迁移后必须在切换 `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 服务并重复健康检查。 diff --git a/docs/features/FEAT-DS-001-aktools-supplement/development-summary.md b/docs/features/FEAT-DS-001-aktools-supplement/development-summary.md index 0e8cb40..6cd2b55 100644 --- a/docs/features/FEAT-DS-001-aktools-supplement/development-summary.md +++ b/docs/features/FEAT-DS-001-aktools-supplement/development-summary.md @@ -1,7 +1,7 @@ # AKTools 超短行情补充数据源开发总结 - 功能编号:FEAT-DS-001 -- 状态:design_approved +- 状态:in_review - 发布状态: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 秒,当前性能证据为本机定向测试,不代表生产容量。 diff --git a/docs/features/FEAT-DS-001-aktools-supplement/gitnexus/comparison.md b/docs/features/FEAT-DS-001-aktools-supplement/gitnexus/comparison.md index d2252f5..fdfd20f 100644 --- a/docs/features/FEAT-DS-001-aktools-supplement/gitnexus/comparison.md +++ b/docs/features/FEAT-DS-001-aktools-supplement/gitnexus/comparison.md @@ -1,3 +1,3 @@ # 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 新执行流。 diff --git a/docs/features/FEAT-DS-001-aktools-supplement/implementation-plan.md b/docs/features/FEAT-DS-001-aktools-supplement/implementation-plan.md index eca6122..b5c6b17 100644 --- a/docs/features/FEAT-DS-001-aktools-supplement/implementation-plan.md +++ b/docs/features/FEAT-DS-001-aktools-supplement/implementation-plan.md @@ -2,19 +2,19 @@ ## 任务清单 -1. 完成功能档案、配置契约和 GitNexus 影响记录。 -2. 为 AKTools 客户端、字段类型和映射器编写失败测试。 -3. 实现固定接口、超时、响应校验和中文字段映射。 -4. 实现短线情绪 Service、规则结论、DTO 和 Controller。 -5. 更新 OpenAPI 生成契约、前端 API、页面模块和异常状态。 -6. 补充开发/生产模板及内网 AKTools 运维说明。 -7. 执行单元、契约、E2E、构建、视觉验证、Review 和治理完成门禁。 +1. [x] 完成功能档案、配置契约和 GitNexus 影响记录。 +2. [x] 为 AKTools 客户端、字段类型和映射器编写失败测试。 +3. [x] 实现固定接口、超时、响应校验和中文字段映射。 +4. [x] 实现短线情绪 Service、规则结论、DTO 和 Controller。 +5. [x] 更新 OpenAPI 生成契约、前端 API、页面模块和异常状态。 +6. [x] 补充开发/生产模板及内网 AKTools 运维说明。 +7. [ ] 执行完整 `pnpm verify`、治理完成门禁和远程推送(收尾阶段)。 ## 文件边界 - 新增 `server/apps/api/src/market-overview/aktools/` 客户端、类型和映射。 - 新增短线业务 DTO、Service 和 Controller;不改动既有 TDX Mapper。 -- 新增 `web/src/api/market-overview/short-term.ts` 或保持业务 API 目录内的等价稳定文件,页面只消费公开 DTO。 +- 修改 `web/src/api/market-overview/index.ts`,页面只消费公开 DTO。 - 修改环境模板、架构文档、开发文档和 OpenAPI 生成产物。 ## 实施顺序 diff --git a/docs/features/FEAT-DS-001-aktools-supplement/manifest.yaml b/docs/features/FEAT-DS-001-aktools-supplement/manifest.yaml index c06b71b..577ab72 100644 --- a/docs/features/FEAT-DS-001-aktools-supplement/manifest.yaml +++ b/docs/features/FEAT-DS-001-aktools-supplement/manifest.yaml @@ -1,7 +1,7 @@ featureId: FEAT-DS-001 slug: aktools-supplement name: AKTools 超短行情补充数据源 -status: design_approved +status: in_review release: unreleased source: aktools scope: internal_lan_only diff --git a/docs/features/FEAT-DS-001-aktools-supplement/qa/test-results.md b/docs/features/FEAT-DS-001-aktools-supplement/qa/test-results.md index 94af423..d27608b 100644 --- a/docs/features/FEAT-DS-001-aktools-supplement/qa/test-results.md +++ b/docs/features/FEAT-DS-001-aktools-supplement/qa/test-results.md @@ -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 秒;当前仅有本机测试证据,不将其视为生产容量结论。 diff --git a/docs/features/FEAT-DS-001-aktools-supplement/qa/visual-qa.md b/docs/features/FEAT-DS-001-aktools-supplement/qa/visual-qa.md index b96777b..581bcaf 100644 --- a/docs/features/FEAT-DS-001-aktools-supplement/qa/visual-qa.md +++ b/docs/features/FEAT-DS-001-aktools-supplement/qa/visual-qa.md @@ -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 失败只改变短线模块状态。视觉检查未展示凭据、真实内网地址或原始异常载荷。 diff --git a/docs/features/FEAT-DS-001-aktools-supplement/release-notes.md b/docs/features/FEAT-DS-001-aktools-supplement/release-notes.md index a7fcc08..0097925 100644 --- a/docs/features/FEAT-DS-001-aktools-supplement/release-notes.md +++ b/docs/features/FEAT-DS-001-aktools-supplement/release-notes.md @@ -2,4 +2,4 @@ ## 未发布 -FEAT-DS-001 当前为未发布功能。计划接入 AKTools 内网补充数据源,提供超短情绪指标和规则结论;不改变 TDX 核心市场概览。 +FEAT-DS-001 当前为未发布功能。已接入 AKTools 内网补充数据源,提供超短情绪指标和规则结论;不改变 TDX 核心市场概览。生产默认关闭,待授权、访问控制和稳定性评估后再决定启用。 diff --git a/docs/features/FEAT-DS-001-aktools-supplement/review/code-review.md b/docs/features/FEAT-DS-001-aktools-supplement/review/code-review.md index 047fe11..d375fcf 100644 --- a/docs/features/FEAT-DS-001-aktools-supplement/review/code-review.md +++ b/docs/features/FEAT-DS-001-aktools-supplement/review/code-review.md @@ -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 与同步修改 @@ -34,4 +34,4 @@ ## 最终结论 -当前阶段:pending +最终结论:passed diff --git a/docs/features/FEAT-DS-001-aktools-supplement/review/standards-review.md b/docs/features/FEAT-DS-001-aktools-supplement/review/standards-review.md index b5f4313..bc8baaa 100644 --- a/docs/features/FEAT-DS-001-aktools-supplement/review/standards-review.md +++ b/docs/features/FEAT-DS-001-aktools-supplement/review/standards-review.md @@ -6,16 +6,16 @@ ## 不符合项与处置 -当前没有代码不符合项;实现阶段若发现问题,必须在发布前修复并记录。 +逐项检查通过:功能档案、中文注释、OpenAPI 生成契约、Axios API 边界、固定数据源 Adapter、模块级降级、环境模板和未提交凭据约束均符合规范。实现期间发现的页面接线遗漏已修复并由前端全量测试复验。 ## 安全、性能与架构检查 -必须确认 AKTools 仅作为外部内网 Adapter,未新增 Docker、生产数据库连接、消息链路或前端直连;同时检查超时、响应上限和降级。 +确认 AKTools 仅作为外部内网 Adapter,未新增 Docker、Python 服务、生产数据库连接、Redis/RocketMQ 消息链路或前端直连;超时、响应上限、并行请求和降级均有实现与测试证据。生产启用的授权、访问控制和容量评估仍是明确前置条件。 ## GitNexus 与开发总结检查 -必须确认开发前后索引、影响计划、变更检测、开发总结、测试结果和 Review 记录完整。 +开发前后 GitNexus 报告、影响计划、变更检测、开发总结、测试结果、视觉 QA、Review 和回退说明均已补齐;`governance:finish` 及最终提交前再次执行变更检测。 ## 最终结论 -当前阶段:pending +最终结论:passed diff --git a/server/apps/api/src/market-overview/aktools/aktools.client.test.ts b/server/apps/api/src/market-overview/aktools/aktools.client.test.ts new file mode 100644 index 0000000..f1c1b11 --- /dev/null +++ b/server/apps/api/src/market-overview/aktools/aktools.client.test.ts @@ -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 请求超时', + }); + }); +}); diff --git a/server/apps/api/src/market-overview/aktools/aktools.client.ts b/server/apps/api/src/market-overview/aktools/aktools.client.ts new file mode 100644 index 0000000..a3ac180 --- /dev/null +++ b/server/apps/api/src/market-overview/aktools/aktools.client.ts @@ -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 { + 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), + }; +} diff --git a/server/apps/api/src/market-overview/aktools/aktools.mapper.test.ts b/server/apps/api/src/market-overview/aktools/aktools.mapper.test.ts new file mode 100644 index 0000000..1e2197d --- /dev/null +++ b/server/apps/api/src/market-overview/aktools/aktools.mapper.test.ts @@ -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, + }); + }); +}); diff --git a/server/apps/api/src/market-overview/aktools/aktools.mapper.ts b/server/apps/api/src/market-overview/aktools/aktools.mapper.ts new file mode 100644 index 0000000..127b648 --- /dev/null +++ b/server/apps/api/src/market-overview/aktools/aktools.mapper.ts @@ -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)['代码']; + 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(); + const industryCounts = new Map(); + 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, + }; +} diff --git a/server/apps/api/src/market-overview/aktools/aktools.types.ts b/server/apps/api/src/market-overview/aktools/aktools.types.ts new file mode 100644 index 0000000..602623c --- /dev/null +++ b/server/apps/api/src/market-overview/aktools/aktools.types.ts @@ -0,0 +1,12 @@ +export type AktoolsRawRow = Readonly>; + +export interface AktoolsDataClient { + getLimitUpPool(tradeDate: string): Promise; + getBrokenBoardPool(tradeDate: string): Promise; + getPreviousLimitUpPool(tradeDate: string): Promise; +} + +export interface AktoolsShortTermSource { + source: 'akshare'; + sourceUpdatedAt: string; +} diff --git a/server/apps/api/src/market-overview/market-overview.module.ts b/server/apps/api/src/market-overview/market-overview.module.ts index cbf81f6..5312ceb 100644 --- a/server/apps/api/src/market-overview/market-overview.module.ts +++ b/server/apps/api/src/market-overview/market-overview.module.ts @@ -3,6 +3,9 @@ import { type DynamicModule, Module } from '@nestjs/common'; import type { ApiRuntimeConfig } from '../../../../libs/config/src/runtime-config'; import { MarketOverviewController } from './market-overview.controller'; 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'; @Module({}) @@ -11,10 +14,12 @@ export class MarketOverviewModule { static register(config: ApiRuntimeConfig): DynamicModule { return { module: MarketOverviewModule, - controllers: [MarketOverviewController], + controllers: [MarketOverviewController, ShortTermMarketController], providers: [ MarketOverviewService, { provide: TDX_CLIENT_PORT, useValue: createTdxClient(config.tdx) }, + ShortTermMarketService, + { provide: AKTOOLS_CLIENT_PORT, useValue: createAktoolsClient(config.aktools) }, ], }; } diff --git a/server/apps/api/src/market-overview/short-term.controller.ts b/server/apps/api/src/market-overview/short-term.controller.ts new file mode 100644 index 0000000..10d05c3 --- /dev/null +++ b/server/apps/api/src/market-overview/short-term.controller.ts @@ -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 { + return this.service.getEmotion(); + } + + @Get('conclusion') + @ApiOkResponse({ type: ShortTermConclusionResponseDto }) + getConclusion(): Promise { + return this.service.getConclusion(); + } +} diff --git a/server/apps/api/src/market-overview/short-term.dto.ts b/server/apps/api/src/market-overview/short-term.dto.ts new file mode 100644 index 0000000..3b5c831 --- /dev/null +++ b/server/apps/api/src/market-overview/short-term.dto.ts @@ -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[]; +} diff --git a/server/apps/api/src/market-overview/short-term.service.test.ts b/server/apps/api/src/market-overview/short-term.service.test.ts new file mode 100644 index 0000000..6122017 --- /dev/null +++ b/server/apps/api/src/market-overview/short-term.service.test.ts @@ -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: '超短情绪偏强' }, + }); + }); +}); diff --git a/server/apps/api/src/market-overview/short-term.service.ts b/server/apps/api/src/market-overview/short-term.service.ts new file mode 100644 index 0000000..74d9ddb --- /dev/null +++ b/server/apps/api/src/market-overview/short-term.service.ts @@ -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 { + 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 { + const emotion = await this.getEmotion(); + return { + ...emotion, + data: emotion.data ? createConclusion(emotion.data) : null, + }; + } + + private readResult( + result: PromiseSettledResult, + mapper: (rows: readonly AktoolsRawRow[]) => T, + ): T | null { + return result.status === 'fulfilled' ? mapper(result.value) : null; + } +} diff --git a/server/apps/api/test/short-term.e2e-spec.ts b/server/apps/api/test/short-term.e2e-spec.ts new file mode 100644 index 0000000..993221d --- /dev/null +++ b/server/apps/api/test/short-term.e2e-spec.ts @@ -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'); + }); +}); diff --git a/server/libs/config/src/runtime-config.test.ts b/server/libs/config/src/runtime-config.test.ts index 2fd28ac..805410a 100644 --- a/server/libs/config/src/runtime-config.test.ts +++ b/server/libs/config/src/runtime-config.test.ts @@ -36,6 +36,12 @@ const disabledAiEnv = { AI_TIMEOUT_MS: '30000', }; +const enabledAktoolsEnv = { + AKTOOLS_ENABLED: 'true', + AKTOOLS_BASE_URL: 'http://127.0.0.1:8080', + AKTOOLS_TIMEOUT_MS: '10000', +}; + const disabledRocketMqEnv = { ROCKETMQ_ENABLED: 'false', ROCKETMQ_NAMESRV_ADDR: '127.0.0.1:9876', @@ -44,6 +50,27 @@ const disabledRocketMqEnv = { }; 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 字段', () => { const config = loadApiConfig({ APP_ENV: 'development', diff --git a/server/libs/config/src/runtime-config.ts b/server/libs/config/src/runtime-config.ts index a7da6de..c96b6e9 100644 --- a/server/libs/config/src/runtime-config.ts +++ b/server/libs/config/src/runtime-config.ts @@ -41,6 +41,13 @@ export interface TdxRuntimeConfig { timeoutMs: number; } +/** AKTools 仅作为本机内网补充源,启用时固定回环地址和端口。 */ +export interface AktoolsRuntimeConfig { + enabled: boolean; + baseUrl: string; + timeoutMs: number; +} + /** RocketMQ 公共寻址配置;实际生产/消费参数由相应进程扩展。 */ export interface RocketMqRuntimeConfig { enabled: boolean; @@ -61,6 +68,7 @@ export interface ApiRuntimeConfig extends CommonRuntimeConfig { processName: 'api'; api: Readonly<{ host: string; port: number }>; tdx: Readonly; + aktools: Readonly; mysql: MysqlRuntimeConfig; 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 { const enabled = parseBoolean(env, 'ROCKETMQ_ENABLED'); 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 }), }, tdx: loadTdxConfig(env), + aktools: loadAktoolsConfig(env), redis: loadRedisConfig(env), mysql: loadMysqlConfig(env), ai: loadAiConfig(env), @@ -335,6 +373,7 @@ export function toSafeRuntimeSummary(config: RuntimeConfig): Readonly { logLevel: 'debug', api: Object.freeze({ host: '127.0.0.1', port: 3000 }), 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, mysql: mysqlConfig, ai: aiConfig, diff --git a/server/tools/export-openapi.ts b/server/tools/export-openapi.ts index 0cd9b29..1e5beba 100644 --- a/server/tools/export-openapi.ts +++ b/server/tools/export-openapi.ts @@ -9,6 +9,7 @@ import { format } from 'prettier'; import { HealthController } from '../apps/api/src/health.controller'; import { MarketOverviewController } from '../apps/api/src/market-overview/market-overview.controller'; +import { ShortTermMarketController } from '../apps/api/src/market-overview/short-term.controller'; import { HealthService, MYSQL_HEALTH_PORT, @@ -18,14 +19,28 @@ import { MarketOverviewService, TDX_CLIENT_PORT, } 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'; @Module({ - controllers: [HealthController, MarketOverviewController], + controllers: [HealthController, MarketOverviewController, ShortTermMarketController], providers: [ HealthService, MarketOverviewService, { 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: MYSQL_HEALTH_PORT, useValue: new DisabledHealthClient('文档生成不连接 MySQL') }, ], diff --git a/web/src/api/generated/schema.ts b/web/src/api/generated/schema.ts index abdef5b..d4c704c 100644 --- a/web/src/api/generated/schema.ts +++ b/web/src/api/generated/schema.ts @@ -84,6 +84,38 @@ export interface paths { patch?: 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': { parameters: { query?: never; @@ -207,6 +239,60 @@ export interface components { status: 'fresh' | 'delayed' | 'partial' | 'unavailable'; 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: { drivers: 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: { parameters: { query?: never; diff --git a/web/src/api/market-overview/index.test.ts b/web/src/api/market-overview/index.test.ts index c92711a..f06bba6 100644 --- a/web/src/api/market-overview/index.test.ts +++ b/web/src/api/market-overview/index.test.ts @@ -17,6 +17,8 @@ describe('市场概览业务 API', () => { await api.getBreadth(); await api.getSectors(); await api.getFunds(); + await api.getShortTermEmotion(); + await api.getShortTermConclusion(); expect(requestMock.mock.calls.map(([config]) => config.url)).toEqual([ '/api/v1/market-overview/summary', @@ -24,6 +26,8 @@ describe('市场概览业务 API', () => { '/api/v1/market-overview/breadth', '/api/v1/market-overview/sectors', '/api/v1/market-overview/funds', + '/api/v1/market-overview/short-term/emotion', + '/api/v1/market-overview/short-term/conclusion', ]); }); }); diff --git a/web/src/api/market-overview/index.ts b/web/src/api/market-overview/index.ts index 1116caf..b214a99 100644 --- a/web/src/api/market-overview/index.ts +++ b/web/src/api/market-overview/index.ts @@ -4,6 +4,8 @@ import type { FundsResponse, IndicesResponse, SectorsResponse, + ShortTermConclusionResponse, + ShortTermEmotionResponse, SummaryResponse, } from '@/types/market-overview'; @@ -19,6 +21,16 @@ export function createMarketOverviewApi(requestClient: RequestClient = request) requestClient({ method: 'get', url: '/api/v1/market-overview/sectors' }), getFunds: () => requestClient({ method: 'get', url: '/api/v1/market-overview/funds' }), + getShortTermEmotion: () => + requestClient({ + method: 'get', + url: '/api/v1/market-overview/short-term/emotion', + }), + getShortTermConclusion: () => + requestClient({ + method: 'get', + url: '/api/v1/market-overview/short-term/conclusion', + }), }; } diff --git a/web/src/styles/index.scss b/web/src/styles/index.scss index 5507aec..e1d9692 100644 --- a/web/src/styles/index.scss +++ b/web/src/styles/index.scss @@ -536,6 +536,86 @@ textarea { 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) { .overview-grid-top, .overview-grid-bottom { diff --git a/web/src/types/market-overview.ts b/web/src/types/market-overview.ts index 9990833..65ff52e 100644 --- a/web/src/types/market-overview.ts +++ b/web/src/types/market-overview.ts @@ -5,10 +5,14 @@ export type IndicesResponse = components['schemas']['IndicesResponseDto']; export type BreadthResponse = components['schemas']['BreadthResponseDto']; 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 SummaryData = NonNullable; export type IndicesData = NonNullable; export type BreadthData = NonNullable; export type SectorsData = NonNullable; export type FundsData = NonNullable; +export type ShortTermEmotionData = NonNullable; +export type ShortTermConclusionData = NonNullable; export type OverviewModuleStatus = 'idle' | 'loading' | 'ready' | 'error'; diff --git a/web/src/views/market-overview/MarketOverviewPage.vue b/web/src/views/market-overview/MarketOverviewPage.vue index 183f075..dae8aa8 100644 --- a/web/src/views/market-overview/MarketOverviewPage.vue +++ b/web/src/views/market-overview/MarketOverviewPage.vue @@ -7,6 +7,7 @@ import MarketDrivers from './components/MarketDrivers.vue'; 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 { useMarketOverview } from './useMarketOverview'; const { @@ -15,6 +16,8 @@ const { breadth, sectors, funds, + shortTermEmotion, + shortTermConclusion, isLoading, analysisHorizon, analysisEngine, @@ -91,6 +94,11 @@ function openDetail(label: string) { @select="openDetail" /> + diff --git a/web/src/views/market-overview/components/MarketDataPanels.test.ts b/web/src/views/market-overview/components/MarketDataPanels.test.ts index 73c0aa1..8eb4aa2 100644 --- a/web/src/views/market-overview/components/MarketDataPanels.test.ts +++ b/web/src/views/market-overview/components/MarketDataPanels.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'; import FundTrend from './FundTrend.vue'; import MarketBreadth from './MarketBreadth.vue'; import SectorOverview from './SectorOverview.vue'; +import ShortTermEmotion from './ShortTermEmotion.vue'; const meta = { snapshotId: '20260818-150000', @@ -152,4 +153,66 @@ describe('市场概览真实数据面板', () => { expect(wrapper.text()).toContain('2026-08-18'); 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('涨停池数据不可用'); + }); }); diff --git a/web/src/views/market-overview/components/ShortTermEmotion.vue b/web/src/views/market-overview/components/ShortTermEmotion.vue new file mode 100644 index 0000000..f7b3735 --- /dev/null +++ b/web/src/views/market-overview/components/ShortTermEmotion.vue @@ -0,0 +1,77 @@ + + + diff --git a/web/src/views/market-overview/useMarketOverview.test.ts b/web/src/views/market-overview/useMarketOverview.test.ts index 4aff54e..ce0e654 100644 --- a/web/src/views/market-overview/useMarketOverview.test.ts +++ b/web/src/views/market-overview/useMarketOverview.test.ts @@ -19,6 +19,8 @@ describe('市场概览状态', () => { getBreadth: vi.fn().mockResolvedValue(response), getSectors: vi.fn().mockResolvedValue(response), getFunds: vi.fn().mockResolvedValue(response), + getShortTermEmotion: vi.fn().mockResolvedValue(response), + getShortTermConclusion: vi.fn().mockResolvedValue(response), }; const state = useMarketOverview(api); @@ -28,6 +30,8 @@ describe('市场概览状态', () => { expect(state.indices.value.status).toBe('error'); expect(state.breadth.value.status).toBe('ready'); expect(state.isLoading.value).toBe(false); + expect(state.shortTermEmotion.value.status).toBe('ready'); + expect(state.shortTermConclusion.value.status).toBe('ready'); }); it('分析偏好写入本地存储并可恢复', () => { diff --git a/web/src/views/market-overview/useMarketOverview.ts b/web/src/views/market-overview/useMarketOverview.ts index b79eb18..da523c9 100644 --- a/web/src/views/market-overview/useMarketOverview.ts +++ b/web/src/views/market-overview/useMarketOverview.ts @@ -7,11 +7,19 @@ import type { IndicesResponse, OverviewModuleStatus, SectorsResponse, + ShortTermConclusionResponse, + ShortTermEmotionResponse, SummaryResponse, } from '@/types/market-overview'; type MarketResponse = - SummaryResponse | IndicesResponse | BreadthResponse | SectorsResponse | FundsResponse; + | SummaryResponse + | IndicesResponse + | BreadthResponse + | SectorsResponse + | FundsResponse + | ShortTermEmotionResponse + | ShortTermConclusionResponse; export interface OverviewModuleState { status: OverviewModuleStatus; @@ -46,10 +54,20 @@ export function useMarketOverview( const breadth = ref>(createState()); const sectors = ref>(createState()); const funds = ref>(createState()); + const shortTermEmotion = ref>(createState()); + const shortTermConclusion = ref>(createState()); const analysisHorizon = ref(readPreference('a-share-analysis:overview-horizon', '综合')); const analysisEngine = ref(readPreference('a-share-analysis:overview-engine', '规则')); 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', ), ); @@ -60,6 +78,8 @@ export function useMarketOverview( breadth.value.status = 'loading'; sectors.value.status = 'loading'; funds.value.status = 'loading'; + shortTermEmotion.value.status = 'loading'; + shortTermConclusion.value.status = 'loading'; } async function loadModule( @@ -83,6 +103,8 @@ export function useMarketOverview( loadModule(breadth, api.getBreadth), loadModule(sectors, api.getSectors), loadModule(funds, api.getFunds), + loadModule(shortTermEmotion, api.getShortTermEmotion), + loadModule(shortTermConclusion, api.getShortTermConclusion), ]); } @@ -102,6 +124,8 @@ export function useMarketOverview( breadth, sectors, funds, + shortTermEmotion, + shortTermConclusion, isLoading, analysisHorizon, analysisEngine,