fix(FEAT-MO-001): 修复TDX数据完整性与状态展示
This commit is contained in:
@@ -27,6 +27,7 @@
|
|||||||
- 对未注册桥接方法返回结构化原因,避免前端显示“实时”或空白数据。
|
- 对未注册桥接方法返回结构化原因,避免前端显示“实时”或空白数据。
|
||||||
- 修复 Vue TypeScript 单文件组件未被 ESLint 正确解析的问题,并为 E2E 测试进程补齐必需的本机 TDX 配置边界。
|
- 修复 Vue TypeScript 单文件组件未被 ESLint 正确解析的问题,并为 E2E 测试进程补齐必需的本机 TDX 配置边界。
|
||||||
- 隔离应用外壳单测中的真实 Axios 请求,消除 JSDOM 网络错误噪声,保持页面测试只验证外壳职责。
|
- 隔离应用外壳单测中的真实 Axios 请求,消除 JSDOM 网络错误噪声,保持页面测试只验证外壳职责。
|
||||||
|
- 根据代码 Review 修复畸形 TDX 字段被折算为零、缺市场成交额误报三市、交易日错位跨日求和、空行业目录误报 `fresh`、前端隐藏 `partial/delayed` 警告和零变化使用上涨色等缺陷,并补齐对应回归测试。
|
||||||
|
|
||||||
## 未完成与延期项
|
## 未完成与延期项
|
||||||
|
|
||||||
@@ -41,9 +42,10 @@
|
|||||||
|
|
||||||
## 验证证据
|
## 验证证据
|
||||||
|
|
||||||
- GitNexus 已完成最终重新索引;提交前 CLI `detect-changes --scope staged` 映射 11 条概览/E2E 执行流并标记 high。高风险来自本功能修改核心概览服务和测试启动入口,已由定向单测、HTTP/OpenAPI、构建和 E2E 覆盖;未发现额外业务模块调用方。MCP context 在当前 worktree 未初始化,因此以 CLI 索引和静态调用证据补充记录。
|
- GitNexus 已完成提交前重新索引;CLI `detect-changes --scope staged` 映射 16 条概览执行流并标记 critical。风险来自本功能修改核心概览服务、TDX 映射和前端数据面板,已由定向单测、HTTP/OpenAPI、前端状态回归、构建、浏览器视觉验证和 E2E 覆盖;未发现额外业务模块调用方。当前环境未暴露 GitNexus MCP 变更检测工具,因此使用同版本 CLI 并指定 worktree 绝对路径执行。
|
||||||
- API 冒烟:`/breadth`、`/sectors`、`/funds` 均返回 `source=tdx,status=fresh`;完整结果和耗时见 `tdx-smoke-report.md`。
|
- API 冒烟:`/breadth`、`/sectors`、`/funds` 均返回 `source=tdx,status=fresh`;完整结果和耗时见 `tdx-smoke-report.md`。
|
||||||
- 前端单测、typecheck、build、后端 TDX/映射/服务/HTTP/OpenAPI 测试通过。
|
- 前端单测、typecheck、build、后端 TDX/映射/服务/HTTP/OpenAPI 测试通过。
|
||||||
- `pnpm verify:e2e`:4/4 通过(桌面和移动端各覆盖页面与健康契约)。
|
- `pnpm verify:e2e`:4/4 通过(桌面和移动端各覆盖页面与健康契约)。
|
||||||
- 浏览器桌面与 390px 移动视口验证无横向溢出,视角与引擎切换可用。
|
- 浏览器桌面与 390px 移动视口验证无横向溢出,视角与引擎切换可用。
|
||||||
|
- Review 首轮在 `ff8ada8` 发现 6 项重要问题;已在当前修复提交中逐项处置,需二次代码 Review 确认。
|
||||||
- 开发 Schema 按用户确认跳过,沿用 `v0.0.10 captured_empty` 追溯边界,不读取生产库。
|
- 开发 Schema 按用户确认跳过,沿用 `v0.0.10 captured_empty` 追溯边界,不读取生产库。
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ npx gitnexus detect-changes --repo "$PWD" --scope staged
|
|||||||
## 结果
|
## 结果
|
||||||
|
|
||||||
- 索引:已完成最终增量更新。
|
- 索引:已完成最终增量更新。
|
||||||
- 暂存文件:38 个文件、87 个符号(包含完整功能档案)。
|
- 暂存文件:20 个文件、67 个符号;GitNexus 自动改写的 `AGENTS.md` 已明确排除。
|
||||||
- 影响执行流:11 条,CLI 风险:high。
|
- 影响执行流:16 条,CLI 风险:critical。
|
||||||
- 主要执行流:`GetSummary`、`GetBreadth`、`GetSectors`、`GetFunds` 和 `RunTestApi`。
|
- 主要执行流:`GetSummary`、`GetIndices`、`GetBreadth`、`GetSectors` 和 `GetFunds`,以及对应映射和状态创建流程。
|
||||||
|
|
||||||
## 处置
|
## 处置
|
||||||
|
|
||||||
high 来自本功能新增并修改市场概览核心服务、共享快照元数据函数和 E2E 启动入口,不是未知跨业务扩散。已用 TDX 客户端/映射/服务/HTTP/OpenAPI 单测、边界测试、构建和 4/4 E2E 覆盖;未发现用户、消息、数据库或 Scheduler/Worker 业务调用方。MCP context 在当前 worktree 未初始化,未将其缺失误判为低风险。
|
critical 来自本功能修改市场概览核心服务、TDX 映射和三个前端数据面板,不是未知跨业务扩散。已用 TDX 映射/服务/HTTP/OpenAPI 单测、前端状态回归、边界测试、构建、浏览器视觉验证和 4/4 E2E 覆盖;未发现用户、消息、数据库、Redis、RocketMQ、Scheduler、Worker、认证或部署调用方。当前环境未暴露 GitNexus MCP 变更检测工具,已使用同版本 CLI 并指定 worktree 绝对路径执行,没有将工具限制误判为低风险。
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
|
|
||||||
## 数据与口径
|
## 数据与口径
|
||||||
|
|
||||||
首个切片由 Nest API 直接读取 TDX 只读桥接层。指数和摘要使用真实 TDX K 线映射;当前桥接层未提供市场宽度、板块排行和资金趋势方法,因此这些模块返回结构化 `unavailable`,不得用指数或离线样本冒充。所有响应仍包含交易日期、快照时间、来源状态、完整性和单位。
|
首个切片由 Nest API 直接读取 TDX 只读桥接层。指数和摘要使用真实 TDX K 线映射;市场宽度使用三市场指数快照,板块使用一级行业目录与日行情,量能使用三市场指数成交额汇总。主力净流入和盘中新闻不在本切片 TDX 主链路中,必须保持明确的补充源边界,不得用指数或离线样本冒充。所有响应仍包含交易日期、快照时间、来源状态、完整性和单位。
|
||||||
|
|
||||||
A 股使用红涨绿跌。成交额统一展示为亿元,比例明确使用百分比。历史数据和低频资金数据必须显示实际数据日期,旧数据不得标记为实时。
|
A 股使用红涨绿跌。成交额统一展示为亿元,比例明确使用百分比。历史数据和低频资金数据必须显示实际数据日期,旧数据不得标记为实时。
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,13 @@
|
|||||||
|
|
||||||
## 结果
|
## 结果
|
||||||
|
|
||||||
- 通过:`document.documentElement.scrollWidth` 未超过视口宽度。
|
- 通过:2026-08-18 使用本地 API/Web 实际页面复验;桌面视口为 `1440×900`,移动视口为 `390×844`。
|
||||||
|
- 通过:桌面 `clientWidth=1440, scrollWidth=1440`,移动端 `clientWidth=390, scrollWidth=390`;移动端六个主要区块左右边界均位于 `14px~376px`,未发生页面级或区块级横向溢出。
|
||||||
- 通过:综合、短线、中长线切换和规则/AI/混合/对照选择有可见状态。
|
- 通过:综合、短线、中长线切换和规则/AI/混合/对照选择有可见状态。
|
||||||
- 通过:TDX 来源、更新时间和不可用原因可见,未把不可用模块渲染为空白。
|
- 通过:实际切换到“综合”和“混合”后,按钮激活态和下拉选中项同步更新。
|
||||||
|
- 通过:TDX 来源、更新时间和不可用原因可见;当前 `127.0.0.1:17710` 未监听时,摘要、指数、宽度、板块和成交额趋势均显示结构化不可用状态,没有空白或伪造实时数据。
|
||||||
|
- 通过:移动端浏览器控制台无 `warning` 或 `error`。
|
||||||
|
|
||||||
## 残余项
|
## 残余项
|
||||||
|
|
||||||
依据抽屉、指数/板块详情页和真实图表尚未立项,不作为本次首屏视觉验收项。
|
依据抽屉、指数/板块详情页和真实图表尚未立项,不作为本次首屏视觉验收项。真实 TDX 数据展示的接口冒烟证据沿用 `tdx-smoke-report.md`;本次隧道未监听,因此没有覆盖或伪造新的真实行情截图。
|
||||||
|
|||||||
@@ -4,10 +4,11 @@
|
|||||||
|
|
||||||
- 新增 TDX 直连的市场概览首屏和五个模块 API。
|
- 新增 TDX 直连的市场概览首屏和五个模块 API。
|
||||||
- 支持综合、短线、中长线视角与规则、AI、混合、对照偏好选择。
|
- 支持综合、短线、中长线视角与规则、AI、混合、对照偏好选择。
|
||||||
- 明确显示 TDX 来源、快照时间、缺失能力和模块级错误。
|
- 市场宽度、一级行业强弱和成交额趋势接入真实 TDX 数据,并对空数据、部分数据、日期不一致和延迟状态显示明确边界。
|
||||||
|
- 明确显示 TDX 来源、快照时间、交易日期、市场覆盖数和模块级错误。
|
||||||
|
|
||||||
## 明确不包含
|
## 明确不包含
|
||||||
|
|
||||||
- 不启用 MySQL、Redis、RocketMQ 或 AI 运行链路。
|
- 不启用 MySQL、Redis、RocketMQ 或 AI 运行链路。
|
||||||
- 不包含用户体系、详情页、依据抽屉、交易操作和荐股能力。
|
- 不包含用户体系、详情页、依据抽屉、交易操作和荐股能力。
|
||||||
- 不包含板块、宽度和资金真实数据,直到 TDX 桥接层提供并验证对应方法。
|
- 不包含主力净流入、盘中新闻、真实图表和详情页;这些能力需另立补充数据源和功能需求。
|
||||||
|
|||||||
@@ -1,5 +1,22 @@
|
|||||||
# 代码 Review
|
# 代码 Review
|
||||||
|
|
||||||
|
## 首轮独立 Review
|
||||||
|
|
||||||
|
- 对比基线:`8261948`
|
||||||
|
- 审查提交:`ff8ada8`
|
||||||
|
- 结论:`Ready to merge: No`
|
||||||
|
|
||||||
|
首轮发现及处置:
|
||||||
|
|
||||||
|
1. 畸形 `UpHome/DownHome` 被折算为零并误标 `fresh`:改为有限、非负且成对校验,无效市场整体排除并按有效市场数返回 `fresh/partial/unavailable`。
|
||||||
|
2. 成交额缺市场或交易日错位仍误标三市实时数据:只汇总非负金额和一致交易日,公开 `marketCount`、`tradeDate`、`previousTradeDate`,缺市场返回 `partial`。
|
||||||
|
3. 空板块目录误标 `fresh`:一级行业为空时直接返回 `unavailable`,且只保留存在相邻两日收盘价和有效涨跌幅的板块。
|
||||||
|
4. 前端隐藏 `partial/delayed` 警告:宽度、板块和成交额面板均展示非 `fresh` 状态提示,并展示板块与成交额真实交易日期。
|
||||||
|
5. PRD、发布说明和 Review 文档残留旧口径:已同步更正为真实 TDX 宽度、一级行业与成交额趋势口径,主力净流入和新闻仍明确延期。
|
||||||
|
6. 异常分支测试不足:新增畸形宽度、部分市场、错位交易日、空行业目录、额外字段隔离、前端状态提示和零变化中性色回归测试。
|
||||||
|
|
||||||
|
次要问题也已同步处理:OpenAPI `required + nullable` 定义、板块 DTO 额外 `price` 字段泄露、成交额零变化上涨色和新增映射中文口径注释。
|
||||||
|
|
||||||
## 范围
|
## 范围
|
||||||
|
|
||||||
覆盖 TDX 客户端、市场概览后端模块、OpenAPI/前端契约、Axios API 封装、Vue 首屏组件和对应测试。
|
覆盖 TDX 客户端、市场概览后端模块、OpenAPI/前端契约、Axios API 封装、Vue 首屏组件和对应测试。
|
||||||
@@ -27,4 +44,8 @@
|
|||||||
- 未进行 7×24、断线重连和生产压测;TDX 宽度/板块/资金方法及详情页仍待独立功能。
|
- 未进行 7×24、断线重连和生产压测;TDX 宽度/板块/资金方法及详情页仍待独立功能。
|
||||||
- ESLint 对 Vue 单文件组件仅保留 35 条既有风格 warning,无 error;格式、类型和构建门禁均通过。
|
- ESLint 对 Vue 单文件组件仅保留 35 条既有风格 warning,无 error;格式、类型和构建门禁均通过。
|
||||||
|
|
||||||
最终结论:passed
|
## 二次 Review
|
||||||
|
|
||||||
|
等待修复提交完成后执行独立二次 Review。
|
||||||
|
|
||||||
|
最终结论:pending
|
||||||
|
|||||||
@@ -15,4 +15,10 @@
|
|||||||
- 开发总结记录原始需求、范围变化、实际完成、修复缺陷、延期项、提交和验证证据。
|
- 开发总结记录原始需求、范围变化、实际完成、修复缺陷、延期项、提交和验证证据。
|
||||||
- QA、发布说明和回退说明与本功能档案保持同一功能编号。
|
- QA、发布说明和回退说明与本功能档案保持同一功能编号。
|
||||||
|
|
||||||
最终结论:passed
|
## 首轮问题同步检查
|
||||||
|
|
||||||
|
- 数据完整性、状态语义、OpenAPI nullable、DTO 输出边界和前端降级提示均已补齐回归测试。
|
||||||
|
- PRD、发布说明、测试计划和开发总结已同步实际口径,避免实现与文档分叉。
|
||||||
|
- 浏览器复验覆盖桌面与 `390×844` 移动视口;当前 TDX 隧道未监听时只确认结构化降级,历史真实行情冒烟证据保持独立。
|
||||||
|
|
||||||
|
最终结论:pending(等待独立二次代码 Review 与完整门禁)
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
- 前端 Vitest、typecheck、build:通过。
|
- 前端 Vitest、typecheck、build:通过。
|
||||||
- 后端 TDX 客户端、映射、服务、HTTP/OpenAPI 测试:通过。
|
- 后端 TDX 客户端、映射、服务、HTTP/OpenAPI 测试:通过。
|
||||||
- `curl` 冒烟:`/breadth`、`/sectors`、`/funds` 均返回 `source=tdx,status=fresh`;分别验证 `UpHome/DownHome` 汇总、一级行业排序和成交额单位转换。
|
- `curl` 冒烟:`/breadth`、`/sectors`、`/funds` 均返回 `source=tdx,status=fresh`;分别验证 `UpHome/DownHome` 汇总、一级行业排序和成交额单位转换。
|
||||||
|
- 异常映射:覆盖畸形涨跌家数字段、空一级行业目录、单根板块 K 线、成交额缺市场/交易日错位/负数,以及三市完整性状态。
|
||||||
|
- 前端状态:覆盖宽度、板块和成交额模块的 `partial`/`delayed` 警告、真实交易日期和成交额零变化中性色。
|
||||||
- 浏览器验证:桌面默认视口和 390px 移动视口无横向溢出;综合/短线切换和分析引擎选择可用。
|
- 浏览器验证:桌面默认视口和 390px 移动视口无横向溢出;综合/短线切换和分析引擎选择可用。
|
||||||
|
|
||||||
## 安全、性能与残余风险
|
## 安全、性能与残余风险
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export class BreadthDataDto {
|
|||||||
@ApiProperty({ type: Number })
|
@ApiProperty({ type: Number })
|
||||||
declining!: number;
|
declining!: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: Number, nullable: true })
|
@ApiProperty({ type: Number, nullable: true })
|
||||||
unchanged!: number | null;
|
unchanged!: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,8 +83,8 @@ export class SectorQuoteDto {
|
|||||||
@ApiProperty({ type: String })
|
@ApiProperty({ type: String })
|
||||||
name!: string;
|
name!: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: Number, nullable: true })
|
@ApiProperty({ type: Number })
|
||||||
changePercent!: number | null;
|
changePercent!: number;
|
||||||
|
|
||||||
@ApiProperty({ enum: ['up', 'down', 'flat'] })
|
@ApiProperty({ enum: ['up', 'down', 'flat'] })
|
||||||
direction!: QuoteDirection;
|
direction!: QuoteDirection;
|
||||||
@@ -110,6 +110,15 @@ export class FundDataDto {
|
|||||||
|
|
||||||
@ApiProperty({ type: String, example: '亿元' })
|
@ApiProperty({ type: String, example: '亿元' })
|
||||||
unit!: '亿元';
|
unit!: '亿元';
|
||||||
|
|
||||||
|
@ApiProperty({ type: Number, minimum: 1, maximum: 3 })
|
||||||
|
marketCount!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, example: '20260818' })
|
||||||
|
tradeDate!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, example: '20260817' })
|
||||||
|
previousTradeDate!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SummaryResponseDto extends MarketOverviewMetaDto {
|
export class SummaryResponseDto extends MarketOverviewMetaDto {
|
||||||
|
|||||||
@@ -118,6 +118,26 @@ describe('市场概览服务', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('市场宽度排除畸形快照并标记部分数据', async () => {
|
||||||
|
const client = {
|
||||||
|
request: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(breadthSnapshot('10', '5'))
|
||||||
|
.mockResolvedValueOnce({ ErrorId: '0', UpHome: '', DownHome: '3' })
|
||||||
|
.mockRejectedValueOnce(new Error('raw network detail')),
|
||||||
|
};
|
||||||
|
const service = new MarketOverviewService(client);
|
||||||
|
|
||||||
|
const response = await service.getBreadth();
|
||||||
|
|
||||||
|
expect(response).toMatchObject({
|
||||||
|
status: 'partial',
|
||||||
|
data: { advancing: 10, declining: 5, unchanged: null },
|
||||||
|
});
|
||||||
|
expect(response.warnings[0]).toContain('深证市场、北证市场');
|
||||||
|
expect(response.warnings.join(' ')).not.toContain('raw network detail');
|
||||||
|
});
|
||||||
|
|
||||||
it('按一级行业涨跌幅排序板块并保留 TDX 代码', async () => {
|
it('按一级行业涨跌幅排序板块并保留 TDX 代码', async () => {
|
||||||
const client = {
|
const client = {
|
||||||
request: vi.fn().mockResolvedValueOnce(sectorResult).mockResolvedValueOnce(sectorKlineResult),
|
request: vi.fn().mockResolvedValueOnce(sectorResult).mockResolvedValueOnce(sectorKlineResult),
|
||||||
@@ -142,6 +162,46 @@ describe('市场概览服务', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('一级行业目录为空时返回不可用而不是 fresh 空数据', async () => {
|
||||||
|
const client = { request: vi.fn().mockResolvedValue({ ErrorId: '0', Value: [] }) };
|
||||||
|
const service = new MarketOverviewService(client);
|
||||||
|
|
||||||
|
const response = await service.getSectors();
|
||||||
|
|
||||||
|
expect(response).toMatchObject({ status: 'unavailable', data: null });
|
||||||
|
expect(response.warnings).toEqual(['一级行业目录为空']);
|
||||||
|
expect(client.request).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('一级行业强弱结果最多展示前八项', async () => {
|
||||||
|
const definitions = Array.from({ length: 9 }, (_, index) => ({
|
||||||
|
Code: `88100${index}.SH`,
|
||||||
|
Name: `行业${index}`,
|
||||||
|
}));
|
||||||
|
const value = Object.fromEntries(
|
||||||
|
definitions.map(({ Code }, index) => [
|
||||||
|
Code,
|
||||||
|
{
|
||||||
|
...indexSeries,
|
||||||
|
Close: ['100', `${101 + index}`],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const client = {
|
||||||
|
request: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({ ErrorId: '0', Value: definitions })
|
||||||
|
.mockResolvedValueOnce({ ErrorId: '0', Value: value }),
|
||||||
|
};
|
||||||
|
const service = new MarketOverviewService(client);
|
||||||
|
|
||||||
|
const response = await service.getSectors();
|
||||||
|
|
||||||
|
expect(response.status).toBe('fresh');
|
||||||
|
expect(response.data?.items).toHaveLength(8);
|
||||||
|
expect(response.data?.items[0].name).toBe('行业8');
|
||||||
|
});
|
||||||
|
|
||||||
it('按交易所指数成交额计算亿元级趋势而不宣称主力净流入', async () => {
|
it('按交易所指数成交额计算亿元级趋势而不宣称主力净流入', async () => {
|
||||||
const client = { request: vi.fn().mockResolvedValue(turnoverResult) };
|
const client = { request: vi.fn().mockResolvedValue(turnoverResult) };
|
||||||
const service = new MarketOverviewService(client);
|
const service = new MarketOverviewService(client);
|
||||||
@@ -156,8 +216,26 @@ describe('市场概览服务', () => {
|
|||||||
previousAmount: 30,
|
previousAmount: 30,
|
||||||
changePercent: 20,
|
changePercent: 20,
|
||||||
unit: '亿元',
|
unit: '亿元',
|
||||||
|
marketCount: 3,
|
||||||
|
tradeDate: '20260818',
|
||||||
|
previousTradeDate: '20260817',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(response.warnings).toEqual([]);
|
expect(response.warnings).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('成交额缺少一个市场时标记 partial 并展示实际市场数', async () => {
|
||||||
|
const partialResult = structuredClone(turnoverResult);
|
||||||
|
delete partialResult.Value['899050.BJ'];
|
||||||
|
const client = { request: vi.fn().mockResolvedValue(partialResult) };
|
||||||
|
const service = new MarketOverviewService(client);
|
||||||
|
|
||||||
|
const response = await service.getFunds();
|
||||||
|
|
||||||
|
expect(response).toMatchObject({
|
||||||
|
status: 'partial',
|
||||||
|
data: { currentAmount: 24, previousAmount: 20, marketCount: 2, tradeDate: '20260818' },
|
||||||
|
});
|
||||||
|
expect(response.warnings).toEqual(['部分市场成交额不可用,当前汇总 2 个市场']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -112,28 +112,34 @@ export class MarketOverviewService {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const snapshots = results.flatMap((result) =>
|
const validSnapshots = results.flatMap((result) => {
|
||||||
result.status === 'fulfilled' ? [result.value] : [],
|
if (result.status === 'rejected') return [];
|
||||||
|
return mapBreadthSnapshots([result.value]).validCount === 1 ? [result.value] : [];
|
||||||
|
});
|
||||||
|
const mapped = mapBreadthSnapshots(validSnapshots);
|
||||||
|
const unavailableMarkets = BREADTH_INDEXES.filter((_, index) => {
|
||||||
|
const result = results[index];
|
||||||
|
return (
|
||||||
|
!result ||
|
||||||
|
result.status === 'rejected' ||
|
||||||
|
mapBreadthSnapshots([result.value]).validCount === 0
|
||||||
);
|
);
|
||||||
if (!snapshots.length) {
|
}).map((item) => item.name);
|
||||||
|
if (!mapped.data) {
|
||||||
return {
|
return {
|
||||||
...meta,
|
...meta,
|
||||||
status: 'unavailable',
|
status: 'unavailable',
|
||||||
data: null,
|
data: null,
|
||||||
warnings: [
|
warnings: [`市场宽度数据不可用:${unavailableMarkets.join('、')}`],
|
||||||
this.toWarning(
|
|
||||||
results[0]?.status === 'rejected' ? results[0].reason : undefined,
|
|
||||||
'市场宽度',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const status = snapshots.length === BREADTH_INDEXES.length ? 'fresh' : 'partial';
|
const status = mapped.validCount === BREADTH_INDEXES.length ? 'fresh' : 'partial';
|
||||||
return {
|
return {
|
||||||
...meta,
|
...meta,
|
||||||
status,
|
status,
|
||||||
data: mapBreadthSnapshots(snapshots),
|
data: mapped.data,
|
||||||
warnings: status === 'partial' ? ['部分市场指数未返回涨跌家数'] : [],
|
warnings:
|
||||||
|
status === 'partial' ? [`部分市场涨跌家数不可用:${unavailableMarkets.join('、')}`] : [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +151,14 @@ export class MarketOverviewService {
|
|||||||
list_type: 1,
|
list_type: 1,
|
||||||
});
|
});
|
||||||
const definitions = catalog.Value.map(({ Code: code, Name: name }) => ({ code, name }));
|
const definitions = catalog.Value.map(({ Code: code, Name: name }) => ({ code, name }));
|
||||||
|
if (!definitions.length) {
|
||||||
|
return {
|
||||||
|
...meta,
|
||||||
|
status: 'unavailable',
|
||||||
|
data: null,
|
||||||
|
warnings: ['一级行业目录为空'],
|
||||||
|
};
|
||||||
|
}
|
||||||
const result = await this.tdxClient.request<TdxMarketDataResult>('get_market_data', {
|
const result = await this.tdxClient.request<TdxMarketDataResult>('get_market_data', {
|
||||||
stock_list: definitions.map((item) => item.code),
|
stock_list: definitions.map((item) => item.code),
|
||||||
count: 2,
|
count: 2,
|
||||||
@@ -187,10 +201,19 @@ export class MarketOverviewService {
|
|||||||
dividend_type: 'none',
|
dividend_type: 'none',
|
||||||
period: '1d',
|
period: '1d',
|
||||||
});
|
});
|
||||||
const data = mapTurnoverTrend(result, TURNOVER_INDEXES);
|
const mapped = mapTurnoverTrend(result, TURNOVER_INDEXES);
|
||||||
if (!data)
|
if (!mapped.data)
|
||||||
return { ...meta, status: 'unavailable', data: null, warnings: ['成交额趋势数据不完整'] };
|
return { ...meta, status: 'unavailable', data: null, warnings: ['成交额趋势数据不完整'] };
|
||||||
return { ...meta, status: 'fresh', data, warnings: [] };
|
const status = mapped.validMarketCount === TURNOVER_INDEXES.length ? 'fresh' : 'partial';
|
||||||
|
return {
|
||||||
|
...meta,
|
||||||
|
status,
|
||||||
|
data: mapped.data,
|
||||||
|
warnings:
|
||||||
|
status === 'partial'
|
||||||
|
? [`部分市场成交额不可用,当前汇总 ${mapped.validMarketCount} 个市场`]
|
||||||
|
: [],
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
...meta,
|
...meta,
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { mapBreadthSnapshots, mapIndexQuotes, mapTurnoverTrend } from './tdx.mapper';
|
import {
|
||||||
|
mapBreadthSnapshots,
|
||||||
|
mapIndexQuotes,
|
||||||
|
mapSectorQuotes,
|
||||||
|
mapTurnoverTrend,
|
||||||
|
} from './tdx.mapper';
|
||||||
|
|
||||||
describe('TDX 行情映射', () => {
|
describe('TDX 行情映射', () => {
|
||||||
it('将 K 线数组转换为 A 股红涨绿跌的指数报价', () => {
|
it('将 K 线数组转换为 A 股红涨绿跌的指数报价', () => {
|
||||||
@@ -62,7 +67,66 @@ describe('TDX 行情映射', () => {
|
|||||||
{ UpHome: '10', DownHome: '5' },
|
{ UpHome: '10', DownHome: '5' },
|
||||||
{ UpHome: '2', DownHome: '3' },
|
{ UpHome: '2', DownHome: '3' },
|
||||||
]),
|
]),
|
||||||
).toEqual({ advancing: 12, declining: 8, unchanged: null });
|
).toEqual({
|
||||||
|
data: { advancing: 12, declining: 8, unchanged: null },
|
||||||
|
validCount: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('市场宽度排除字段缺失、非数值和负数的快照', () => {
|
||||||
|
expect(
|
||||||
|
mapBreadthSnapshots([
|
||||||
|
{ UpHome: '10', DownHome: '5' },
|
||||||
|
{ UpHome: '', DownHome: '3' },
|
||||||
|
{ UpHome: 'NaN', DownHome: '2' },
|
||||||
|
{ UpHome: '-1', DownHome: '2' },
|
||||||
|
]),
|
||||||
|
).toEqual({
|
||||||
|
data: { advancing: 10, declining: 5, unchanged: null },
|
||||||
|
validCount: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('板块只保留有两个交易日和有效涨跌幅的精确字段', () => {
|
||||||
|
const result = mapSectorQuotes(
|
||||||
|
{
|
||||||
|
ErrorId: '0',
|
||||||
|
Value: {
|
||||||
|
'881001.SH': {
|
||||||
|
Date: ['20260817', '20260818'],
|
||||||
|
Open: ['1', '1'],
|
||||||
|
High: ['1', '1'],
|
||||||
|
Low: ['1', '1'],
|
||||||
|
Close: ['100', '102'],
|
||||||
|
Amount: ['1', '1'],
|
||||||
|
Volume: ['1', '1'],
|
||||||
|
},
|
||||||
|
'881002.SH': {
|
||||||
|
Date: ['20260818'],
|
||||||
|
Open: ['1'],
|
||||||
|
High: ['1'],
|
||||||
|
Low: ['1'],
|
||||||
|
Close: ['100'],
|
||||||
|
Amount: ['1'],
|
||||||
|
Volume: ['1'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[
|
||||||
|
{ code: '881001.SH', name: '行业一' },
|
||||||
|
{ code: '881002.SH', name: '行业二' },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
code: '881001.SH',
|
||||||
|
name: '行业一',
|
||||||
|
changePercent: 2,
|
||||||
|
direction: 'up',
|
||||||
|
tradeDate: '20260818',
|
||||||
|
},
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('成交额映射将 TDX 万元转换为亿元并计算环比', () => {
|
it('成交额映射将 TDX 万元转换为亿元并计算环比', () => {
|
||||||
@@ -84,6 +148,49 @@ describe('TDX 行情映射', () => {
|
|||||||
},
|
},
|
||||||
[{ code: '000001.SH', name: '上证市场' }],
|
[{ code: '000001.SH', name: '上证市场' }],
|
||||||
),
|
),
|
||||||
).toEqual({ currentAmount: 12, previousAmount: 10, changePercent: 20, unit: '亿元' });
|
).toEqual({
|
||||||
|
data: {
|
||||||
|
currentAmount: 12,
|
||||||
|
previousAmount: 10,
|
||||||
|
changePercent: 20,
|
||||||
|
unit: '亿元',
|
||||||
|
marketCount: 1,
|
||||||
|
tradeDate: '20260818',
|
||||||
|
previousTradeDate: '20260817',
|
||||||
|
},
|
||||||
|
validMarketCount: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('成交额只汇总交易日期一致且金额非负的市场', () => {
|
||||||
|
const series = (dates: string[], amounts: string[]) => ({
|
||||||
|
Date: dates,
|
||||||
|
Open: ['1', '1'],
|
||||||
|
High: ['1', '1'],
|
||||||
|
Low: ['1', '1'],
|
||||||
|
Close: ['1', '1'],
|
||||||
|
Amount: amounts,
|
||||||
|
Volume: ['1', '1'],
|
||||||
|
});
|
||||||
|
const result = mapTurnoverTrend(
|
||||||
|
{
|
||||||
|
ErrorId: '0',
|
||||||
|
Value: {
|
||||||
|
'000001.SH': series(['20260817', '20260818'], ['100000', '120000']),
|
||||||
|
'399001.SZ': series(['20260816', '20260817'], ['200000', '240000']),
|
||||||
|
'899050.BJ': series(['20260817', '20260818'], ['10000', '-1']),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[
|
||||||
|
{ code: '000001.SH', name: '上证市场' },
|
||||||
|
{ code: '399001.SZ', name: '深证市场' },
|
||||||
|
{ code: '899050.BJ', name: '北证市场' },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
validMarketCount: 1,
|
||||||
|
data: { currentAmount: 12, previousAmount: 10, marketCount: 1, tradeDate: '20260818' },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ function toNumber(value: string | undefined): number | null {
|
|||||||
return Number.isFinite(parsed) ? parsed : null;
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toNonNegativeNumber(value: string | undefined): number | null {
|
||||||
|
const parsed = toNumber(value);
|
||||||
|
return parsed !== null && parsed >= 0 ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
function mapDirection(changePercent: number | null): IndexQuoteDto['direction'] {
|
function mapDirection(changePercent: number | null): IndexQuoteDto['direction'] {
|
||||||
if (changePercent === null || changePercent === 0) return 'flat';
|
if (changePercent === null || changePercent === 0) return 'flat';
|
||||||
return changePercent > 0 ? 'up' : 'down';
|
return changePercent > 0 ? 'up' : 'down';
|
||||||
@@ -52,50 +57,98 @@ export function mapIndexQuotes(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapBreadthSnapshots(
|
/** TDX 涨跌家数必须成对有效且非负;无效市场整体排除,禁止把缺失字段折算为零。 */
|
||||||
snapshots: readonly { UpHome?: string; DownHome?: string }[],
|
export function mapBreadthSnapshots(snapshots: readonly { UpHome?: string; DownHome?: string }[]): {
|
||||||
): BreadthDataDto {
|
data: BreadthDataDto | null;
|
||||||
const advancing = snapshots.reduce((total, item) => total + (toNumber(item.UpHome) ?? 0), 0);
|
validCount: number;
|
||||||
const declining = snapshots.reduce((total, item) => total + (toNumber(item.DownHome) ?? 0), 0);
|
} {
|
||||||
return { advancing, declining, unchanged: null };
|
const validSnapshots = snapshots.flatMap((item) => {
|
||||||
|
const advancing = toNonNegativeNumber(item.UpHome);
|
||||||
|
const declining = toNonNegativeNumber(item.DownHome);
|
||||||
|
return advancing === null || declining === null ? [] : [{ advancing, declining }];
|
||||||
|
});
|
||||||
|
if (!validSnapshots.length) return { data: null, validCount: 0 };
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
advancing: validSnapshots.reduce((total, item) => total + item.advancing, 0),
|
||||||
|
declining: validSnapshots.reduce((total, item) => total + item.declining, 0),
|
||||||
|
unchanged: null,
|
||||||
|
},
|
||||||
|
validCount: validSnapshots.length,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 一级行业只有在具备相邻两日收盘价时才参与强弱排序,返回对象严格匹配公开 DTO。 */
|
||||||
export function mapSectorQuotes(
|
export function mapSectorQuotes(
|
||||||
result: TdxMarketDataResult,
|
result: TdxMarketDataResult,
|
||||||
definitions: readonly IndexDefinition[],
|
definitions: readonly IndexDefinition[],
|
||||||
): SectorQuoteDto[] {
|
): SectorQuoteDto[] {
|
||||||
return mapIndexQuotes(result, definitions).sort((left, right) => {
|
return definitions
|
||||||
return (
|
.flatMap((definition) => {
|
||||||
(right.changePercent ?? Number.NEGATIVE_INFINITY) -
|
const series = result.Value[definition.code];
|
||||||
(left.changePercent ?? Number.NEGATIVE_INFINITY)
|
if (!series) return [];
|
||||||
);
|
const quote = mapIndexQuote(series, definition);
|
||||||
});
|
if (!quote || quote.changePercent === null) return [];
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
code: quote.code,
|
||||||
|
name: quote.name,
|
||||||
|
changePercent: quote.changePercent,
|
||||||
|
direction: quote.direction,
|
||||||
|
tradeDate: quote.tradeDate,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
})
|
||||||
|
.sort((left, right) => right.changePercent - left.changePercent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 成交额仅汇总交易日期对齐、金额非负的市场;TDX Amount 从万元转换为亿元。 */
|
||||||
export function mapTurnoverTrend(
|
export function mapTurnoverTrend(
|
||||||
result: TdxMarketDataResult,
|
result: TdxMarketDataResult,
|
||||||
definitions: readonly IndexDefinition[],
|
definitions: readonly IndexDefinition[],
|
||||||
): FundDataDto | null {
|
): { data: FundDataDto | null; validMarketCount: number } {
|
||||||
let currentAmount = 0;
|
const candidates = definitions.flatMap((definition) => {
|
||||||
let previousAmount = 0;
|
|
||||||
let validSeries = 0;
|
|
||||||
|
|
||||||
for (const definition of definitions) {
|
|
||||||
const series = result.Value[definition.code];
|
const series = result.Value[definition.code];
|
||||||
if (!series) continue;
|
if (!series) return [];
|
||||||
const current = toNumber(series.Amount.at(-1));
|
const current = toNonNegativeNumber(series.Amount.at(-1));
|
||||||
const previous = toNumber(series.Amount.at(-2));
|
const previous = toNonNegativeNumber(series.Amount.at(-2));
|
||||||
if (current === null || previous === null) continue;
|
const tradeDate = series.Date.at(-1);
|
||||||
currentAmount += current;
|
const previousTradeDate = series.Date.at(-2);
|
||||||
previousAmount += previous;
|
if (
|
||||||
validSeries += 1;
|
current === null ||
|
||||||
|
previous === null ||
|
||||||
|
previous === 0 ||
|
||||||
|
!tradeDate ||
|
||||||
|
!previousTradeDate ||
|
||||||
|
tradeDate === previousTradeDate
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
return [{ current, previous, tradeDate, previousTradeDate }];
|
||||||
|
});
|
||||||
|
if (!candidates.length) return { data: null, validMarketCount: 0 };
|
||||||
|
|
||||||
|
const expected = candidates[0];
|
||||||
|
const aligned = candidates.filter(
|
||||||
|
(item) =>
|
||||||
|
item.tradeDate === expected.tradeDate &&
|
||||||
|
item.previousTradeDate === expected.previousTradeDate,
|
||||||
|
);
|
||||||
|
const currentAmount = aligned.reduce((total, item) => total + item.current, 0);
|
||||||
|
const previousAmount = aligned.reduce((total, item) => total + item.previous, 0);
|
||||||
|
if (previousAmount === 0) return { data: null, validMarketCount: aligned.length };
|
||||||
|
|
||||||
if (!validSeries || previousAmount === 0) return null;
|
|
||||||
return {
|
return {
|
||||||
|
data: {
|
||||||
currentAmount: Math.round((currentAmount / 10000) * 100) / 100,
|
currentAmount: Math.round((currentAmount / 10000) * 100) / 100,
|
||||||
previousAmount: Math.round((previousAmount / 10000) * 100) / 100,
|
previousAmount: Math.round((previousAmount / 10000) * 100) / 100,
|
||||||
changePercent: Math.round(((currentAmount - previousAmount) / previousAmount) * 10000) / 100,
|
changePercent: Math.round(((currentAmount - previousAmount) / previousAmount) * 10000) / 100,
|
||||||
unit: '亿元',
|
unit: '亿元',
|
||||||
|
marketCount: aligned.length,
|
||||||
|
tradeDate: expected.tradeDate,
|
||||||
|
previousTradeDate: expected.previousTradeDate,
|
||||||
|
},
|
||||||
|
validMarketCount: aligned.length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"declining": { "type": "number" },
|
"declining": { "type": "number" },
|
||||||
"unchanged": { "nullable": true, "type": "number" }
|
"unchanged": { "nullable": true, "type": "number" }
|
||||||
},
|
},
|
||||||
"required": ["advancing", "declining"],
|
"required": ["advancing", "declining", "unchanged"],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
"BreadthResponseDto": {
|
"BreadthResponseDto": {
|
||||||
@@ -44,10 +44,21 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"changePercent": { "type": "number" },
|
"changePercent": { "type": "number" },
|
||||||
"currentAmount": { "type": "number" },
|
"currentAmount": { "type": "number" },
|
||||||
|
"marketCount": { "maximum": 3, "minimum": 1, "type": "number" },
|
||||||
"previousAmount": { "type": "number" },
|
"previousAmount": { "type": "number" },
|
||||||
|
"previousTradeDate": { "example": "20260817", "type": "string" },
|
||||||
|
"tradeDate": { "example": "20260818", "type": "string" },
|
||||||
"unit": { "example": "亿元", "type": "string" }
|
"unit": { "example": "亿元", "type": "string" }
|
||||||
},
|
},
|
||||||
"required": ["currentAmount", "previousAmount", "changePercent", "unit"],
|
"required": [
|
||||||
|
"currentAmount",
|
||||||
|
"previousAmount",
|
||||||
|
"changePercent",
|
||||||
|
"unit",
|
||||||
|
"marketCount",
|
||||||
|
"tradeDate",
|
||||||
|
"previousTradeDate"
|
||||||
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
"FundsResponseDto": {
|
"FundsResponseDto": {
|
||||||
@@ -143,13 +154,13 @@
|
|||||||
},
|
},
|
||||||
"SectorQuoteDto": {
|
"SectorQuoteDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"changePercent": { "nullable": true, "type": "number" },
|
"changePercent": { "type": "number" },
|
||||||
"code": { "type": "string" },
|
"code": { "type": "string" },
|
||||||
"direction": { "enum": ["up", "down", "flat"], "type": "string" },
|
"direction": { "enum": ["up", "down", "flat"], "type": "string" },
|
||||||
"name": { "type": "string" },
|
"name": { "type": "string" },
|
||||||
"tradeDate": { "type": "string" }
|
"tradeDate": { "type": "string" }
|
||||||
},
|
},
|
||||||
"required": ["code", "name", "direction", "tradeDate"],
|
"required": ["code", "name", "changePercent", "direction", "tradeDate"],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
"SectorsResponseDto": {
|
"SectorsResponseDto": {
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export interface components {
|
|||||||
BreadthDataDto: {
|
BreadthDataDto: {
|
||||||
advancing: number;
|
advancing: number;
|
||||||
declining: number;
|
declining: number;
|
||||||
unchanged?: number | null;
|
unchanged: number | null;
|
||||||
};
|
};
|
||||||
BreadthResponseDto: {
|
BreadthResponseDto: {
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
@@ -128,7 +128,12 @@ export interface components {
|
|||||||
FundDataDto: {
|
FundDataDto: {
|
||||||
changePercent: number;
|
changePercent: number;
|
||||||
currentAmount: number;
|
currentAmount: number;
|
||||||
|
marketCount: number;
|
||||||
previousAmount: number;
|
previousAmount: number;
|
||||||
|
/** @example 20260817 */
|
||||||
|
previousTradeDate: string;
|
||||||
|
/** @example 20260818 */
|
||||||
|
tradeDate: string;
|
||||||
/** @example 亿元 */
|
/** @example 亿元 */
|
||||||
unit: string;
|
unit: string;
|
||||||
};
|
};
|
||||||
@@ -184,7 +189,7 @@ export interface components {
|
|||||||
items: components['schemas']['SectorQuoteDto'][];
|
items: components['schemas']['SectorQuoteDto'][];
|
||||||
};
|
};
|
||||||
SectorQuoteDto: {
|
SectorQuoteDto: {
|
||||||
changePercent?: number | null;
|
changePercent: number;
|
||||||
code: string;
|
code: string;
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
direction: 'up' | 'down' | 'flat';
|
direction: 'up' | 'down' | 'flat';
|
||||||
|
|||||||
@@ -297,6 +297,18 @@ textarea {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.overview-panel .module-warning {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
border-left: 3px solid #b67a2d;
|
||||||
|
background: #fff8ec;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-panel .module-warning strong {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.overview-grid {
|
.overview-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
@@ -497,6 +509,10 @@ textarea {
|
|||||||
color: #19846e;
|
color: #19846e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.turnover-trend [data-direction='flat'] {
|
||||||
|
color: #6f7a84;
|
||||||
|
}
|
||||||
|
|
||||||
.turnover-trend {
|
.turnover-trend {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
import type { FundsResponse } from '@/types/market-overview';
|
import type { FundsResponse } from '@/types/market-overview';
|
||||||
|
|
||||||
defineProps<{ response: FundsResponse | null; loading: boolean }>();
|
defineProps<{ response: FundsResponse | null; loading: boolean }>();
|
||||||
|
|
||||||
|
function formatTradeDate(value: string | undefined): string {
|
||||||
|
return value?.length === 8 ? `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6)}` : '—';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -11,25 +15,39 @@ defineProps<{ response: FundsResponse | null; loading: boolean }>();
|
|||||||
<span class="section-kicker">量能观察</span>
|
<span class="section-kicker">量能观察</span>
|
||||||
<h2 id="funds-title">成交额趋势</h2>
|
<h2 id="funds-title">成交额趋势</h2>
|
||||||
</div>
|
</div>
|
||||||
<span class="panel-meta">TDX · 亿元</span>
|
<span class="panel-meta">{{ formatTradeDate(response?.data?.tradeDate) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="loading" class="module-placeholder">正在读取资金趋势…</div>
|
<div v-if="loading" class="module-placeholder">正在读取资金趋势…</div>
|
||||||
<div v-else-if="response?.status === 'unavailable'" class="module-unavailable">
|
<div v-else-if="response?.status === 'unavailable'" class="module-unavailable">
|
||||||
<strong>暂不可用</strong>
|
<strong>暂不可用</strong>
|
||||||
<span>{{ response.warnings[0] }}</span>
|
<span>{{ response.warnings[0] }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="response?.data" class="turnover-trend">
|
<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="turnover-trend">
|
||||||
<div>
|
<div>
|
||||||
<span>三市成交额</span>
|
<span>{{ response.data.marketCount }} 市成交额</span>
|
||||||
<strong>{{ response.data.currentAmount.toLocaleString('zh-CN') }}</strong>
|
<strong>{{ response.data.currentAmount.toLocaleString('zh-CN') }}</strong>
|
||||||
<small>{{ response.data.unit }}</small>
|
<small>{{ response.data.unit }}</small>
|
||||||
</div>
|
</div>
|
||||||
<p :data-direction="response.data.changePercent >= 0 ? 'up' : 'down'">
|
<p
|
||||||
|
:data-direction="
|
||||||
|
response.data.changePercent === 0
|
||||||
|
? 'flat'
|
||||||
|
: response.data.changePercent > 0
|
||||||
|
? 'up'
|
||||||
|
: 'down'
|
||||||
|
"
|
||||||
|
>
|
||||||
较前一交易日
|
较前一交易日
|
||||||
{{ response.data.changePercent > 0 ? '+' : ''
|
{{ response.data.changePercent > 0 ? '+' : ''
|
||||||
}}{{ response.data.changePercent.toFixed(2) }}%
|
}}{{ response.data.changePercent.toFixed(2) }}%
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
<div v-else class="module-placeholder">暂无成交额趋势数据</div>
|
<div v-else class="module-placeholder">暂无成交额趋势数据</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -18,7 +18,12 @@ defineProps<{ response: BreadthResponse | null; loading: boolean }>();
|
|||||||
<strong>暂不可用</strong>
|
<strong>暂不可用</strong>
|
||||||
<span>{{ response.warnings[0] }}</span>
|
<span>{{ response.warnings[0] }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="response?.data" class="breadth-values">
|
<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="breadth-values">
|
||||||
<div>
|
<div>
|
||||||
<span>上涨</span><strong class="positive">{{ response.data.advancing }}</strong>
|
<span>上涨</span><strong class="positive">{{ response.data.advancing }}</strong>
|
||||||
</div>
|
</div>
|
||||||
@@ -29,6 +34,7 @@ defineProps<{ response: BreadthResponse | null; loading: boolean }>();
|
|||||||
<span>平盘</span><strong>{{ response.data.unchanged ?? '—' }}</strong>
|
<span>平盘</span><strong>{{ response.data.unchanged ?? '—' }}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
<div v-else class="module-placeholder">暂无宽度数据</div>
|
<div v-else class="module-placeholder">暂无宽度数据</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -54,6 +54,52 @@ describe('市场概览真实数据面板', () => {
|
|||||||
expect(wrapper.emitted('select')).toEqual([['881319.SH']]);
|
expect(wrapper.emitted('select')).toEqual([['881319.SH']]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('市场宽度 partial 状态显示警告且保留有效数据', () => {
|
||||||
|
const wrapper = mount(MarketBreadth, {
|
||||||
|
props: {
|
||||||
|
loading: false,
|
||||||
|
response: {
|
||||||
|
...meta,
|
||||||
|
status: 'partial',
|
||||||
|
warnings: ['部分市场涨跌家数不可用:北证市场'],
|
||||||
|
data: { advancing: 10, declining: 5, unchanged: null },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('部分数据');
|
||||||
|
expect(wrapper.text()).toContain('北证市场');
|
||||||
|
expect(wrapper.text()).toContain('10');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('板块 delayed 状态显示警告和真实交易日期', () => {
|
||||||
|
const wrapper = mount(SectorOverview, {
|
||||||
|
props: {
|
||||||
|
loading: false,
|
||||||
|
response: {
|
||||||
|
...meta,
|
||||||
|
status: 'delayed',
|
||||||
|
warnings: ['板块数据已延迟'],
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
code: '881319.SH',
|
||||||
|
name: '半导体',
|
||||||
|
changePercent: 2.56,
|
||||||
|
direction: 'up',
|
||||||
|
tradeDate: '20260818',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('数据延迟');
|
||||||
|
expect(wrapper.text()).toContain('板块数据已延迟');
|
||||||
|
expect(wrapper.text()).toContain('2026-08-18');
|
||||||
|
});
|
||||||
|
|
||||||
it('资金模块明确展示成交额和与上一交易日的变化', () => {
|
it('资金模块明确展示成交额和与上一交易日的变化', () => {
|
||||||
const wrapper = mount(FundTrend, {
|
const wrapper = mount(FundTrend, {
|
||||||
props: {
|
props: {
|
||||||
@@ -65,6 +111,9 @@ describe('市场概览真实数据面板', () => {
|
|||||||
previousAmount: 11000,
|
previousAmount: 11000,
|
||||||
changePercent: 12.23,
|
changePercent: 12.23,
|
||||||
unit: '亿元' as const,
|
unit: '亿元' as const,
|
||||||
|
marketCount: 3,
|
||||||
|
tradeDate: '20260818',
|
||||||
|
previousTradeDate: '20260817',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -75,4 +124,32 @@ describe('市场概览真实数据面板', () => {
|
|||||||
expect(wrapper.text()).toContain('较前一交易日 +12.23%');
|
expect(wrapper.text()).toContain('较前一交易日 +12.23%');
|
||||||
expect(wrapper.text()).not.toContain('主力净流入');
|
expect(wrapper.text()).not.toContain('主力净流入');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('部分数据保留数值并显示警告和实际交易日期', () => {
|
||||||
|
const wrapper = mount(FundTrend, {
|
||||||
|
props: {
|
||||||
|
loading: false,
|
||||||
|
response: {
|
||||||
|
...meta,
|
||||||
|
status: 'partial',
|
||||||
|
warnings: ['部分市场成交额不可用,当前汇总 2 个市场'],
|
||||||
|
data: {
|
||||||
|
currentAmount: 100,
|
||||||
|
previousAmount: 100,
|
||||||
|
changePercent: 0,
|
||||||
|
unit: '亿元' as const,
|
||||||
|
marketCount: 2,
|
||||||
|
tradeDate: '20260818',
|
||||||
|
previousTradeDate: '20260817',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('部分数据');
|
||||||
|
expect(wrapper.text()).toContain('部分市场成交额不可用');
|
||||||
|
expect(wrapper.text()).toContain('2 市成交额');
|
||||||
|
expect(wrapper.text()).toContain('2026-08-18');
|
||||||
|
expect(wrapper.get('[data-direction="flat"]').attributes('data-direction')).toBe('flat');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import type { SectorsResponse } from '@/types/market-overview';
|
|||||||
|
|
||||||
defineProps<{ response: SectorsResponse | null; loading: boolean }>();
|
defineProps<{ response: SectorsResponse | null; loading: boolean }>();
|
||||||
const emit = defineEmits<{ select: [sector: string] }>();
|
const emit = defineEmits<{ select: [sector: string] }>();
|
||||||
|
|
||||||
|
function formatTradeDate(value: string | undefined): string {
|
||||||
|
return value?.length === 8 ? `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6)}` : '—';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -12,14 +16,19 @@ const emit = defineEmits<{ select: [sector: string] }>();
|
|||||||
<span class="section-kicker">板块扫描</span>
|
<span class="section-kicker">板块扫描</span>
|
||||||
<h2 id="sectors-title">强弱分布</h2>
|
<h2 id="sectors-title">强弱分布</h2>
|
||||||
</div>
|
</div>
|
||||||
<span class="panel-meta">点击查看详情</span>
|
<span class="panel-meta">{{ formatTradeDate(response?.data?.items[0]?.tradeDate) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="loading" class="module-placeholder">正在读取板块…</div>
|
<div v-if="loading" class="module-placeholder">正在读取板块…</div>
|
||||||
<div v-else-if="response?.status === 'unavailable'" class="module-unavailable">
|
<div v-else-if="response?.status === 'unavailable'" class="module-unavailable">
|
||||||
<strong>暂不可用</strong>
|
<strong>暂不可用</strong>
|
||||||
<span>{{ response.warnings[0] }}</span>
|
<span>{{ response.warnings[0] }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="response?.data?.items.length" class="sector-list">
|
<template v-else-if="response?.data?.items.length">
|
||||||
|
<div v-if="response.status !== 'fresh'" class="module-warning">
|
||||||
|
<strong>{{ response.status === 'partial' ? '部分数据' : '数据延迟' }}</strong>
|
||||||
|
<span>{{ response.warnings[0] }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="sector-list">
|
||||||
<button
|
<button
|
||||||
v-for="sector in response.data.items"
|
v-for="sector in response.data.items"
|
||||||
:key="sector.code"
|
:key="sector.code"
|
||||||
@@ -29,15 +38,12 @@ const emit = defineEmits<{ select: [sector: string] }>();
|
|||||||
@click="emit('select', sector.code)"
|
@click="emit('select', sector.code)"
|
||||||
>
|
>
|
||||||
<span>{{ sector.name }}</span>
|
<span>{{ sector.name }}</span>
|
||||||
<strong>
|
<strong>{{
|
||||||
{{
|
`${sector.changePercent > 0 ? '+' : ''}${sector.changePercent.toFixed(2)}%`
|
||||||
sector.changePercent == null
|
}}</strong>
|
||||||
? '—'
|
|
||||||
: `${sector.changePercent > 0 ? '+' : ''}${sector.changePercent.toFixed(2)}%`
|
|
||||||
}}
|
|
||||||
</strong>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
<div v-else class="module-placeholder">暂无板块数据</div>
|
<div v-else class="module-placeholder">暂无板块数据</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user