feat(FEAT-DS-001): 接入AKTools超短行情补充数据源

This commit is contained in:
2026-08-24 21:40:22 +08:00
parent 81c48af8cc
commit 6e8a05114e
42 changed files with 1449 additions and 36 deletions
+1 -1
View File
@@ -29,7 +29,7 @@
<!-- gitnexus:start -->
# 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.
+4 -1
View File
@@ -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 连接方式
+4
View File
@@ -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
+4
View File
@@ -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
+2
View File
@@ -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 实现。
+10
View File
@@ -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
@@ -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 服务并重复健康检查。
@@ -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 秒,当前性能证据为本机定向测试,不代表生产容量。
@@ -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 新执行流。
@@ -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 生成产物。
## 实施顺序
@@ -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
@@ -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 与同步修改
@@ -34,4 +34,4 @@
## 最终结论
当前阶段:pending
最终结论:passed
@@ -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
@@ -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 { 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) },
],
};
}
@@ -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',
};
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',
+39
View File
@@ -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<TdxRuntimeConfig>;
aktools: Readonly<AktoolsRuntimeConfig>;
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<Record<str
};
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 === 'worker') summary.marketData = { mode: config.marketData.mode };
+133
View File
@@ -182,6 +182,101 @@
"required": ["snapshotId", "asOf", "source", "status", "warnings", "data"],
"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": {
"properties": {
"drivers": { "items": { "type": "string" }, "type": "array" },
@@ -323,6 +418,44 @@
"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": {
"get": {
"operationId": "MarketOverviewController_getSummary",
+1
View File
@@ -60,6 +60,7 @@ describe('NestJS 三进程入口', () => {
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,
+16 -1
View File
@@ -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') },
],
+124
View File
@@ -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;
@@ -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',
]);
});
});
+12
View File
@@ -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<SectorsResponse>({ method: 'get', url: '/api/v1/market-overview/sectors' }),
getFunds: () =>
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',
}),
};
}
+80
View File
@@ -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 {
+4
View File
@@ -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<SummaryResponse['data']>;
export type IndicesData = NonNullable<IndicesResponse['data']>;
export type BreadthData = NonNullable<BreadthResponse['data']>;
export type SectorsData = NonNullable<SectorsResponse['data']>;
export type FundsData = NonNullable<FundsResponse['data']>;
export type ShortTermEmotionData = NonNullable<ShortTermEmotionResponse['data']>;
export type ShortTermConclusionData = NonNullable<ShortTermConclusionResponse['data']>;
export type OverviewModuleStatus = 'idle' | 'loading' | 'ready' | 'error';
@@ -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"
/>
<FundTrend :response="funds.response" :loading="funds.status === 'loading'" />
<ShortTermEmotion
:response="shortTermEmotion.response"
:loading="shortTermEmotion.status === 'loading'"
:conclusion="shortTermConclusion.response"
/>
</div>
</div>
</template>
@@ -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('涨停池数据不可用');
});
});
@@ -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),
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('分析偏好写入本地存储并可恢复', () => {
@@ -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<TResponse extends MarketResponse> {
status: OverviewModuleStatus;
@@ -46,10 +54,20 @@ export function useMarketOverview(
const breadth = ref<OverviewModuleState<BreadthResponse>>(createState());
const sectors = ref<OverviewModuleState<SectorsResponse>>(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 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<TResponse extends MarketResponse>(
@@ -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,