From 49f6be9c433d5f81de6b06ac36bcb0bf8c31221f Mon Sep 17 00:00:00 2001 From: Citrons <909229731@qq.com> Date: Tue, 18 Aug 2026 19:30:34 +0800 Subject: [PATCH] =?UTF-8?q?fix(FEAT-MO-001):=20=E4=BF=AE=E5=A4=8DTDX?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=AE=8C=E6=95=B4=E6=80=A7=E4=B8=8E=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../development-summary.md | 4 +- .../gitnexus/detected-changes.md | 8 +- .../FEAT-MO-001-market-overview/prd.md | 2 +- .../qa/visual-qa.md | 9 +- .../release-notes.md | 5 +- .../review/code-review.md | 23 +++- .../review/standards-review.md | 8 +- .../FEAT-MO-001-market-overview/test-plan.md | 2 + .../market-overview/market-overview.dto.ts | 15 ++- .../market-overview.service.test.ts | 78 ++++++++++++ .../market-overview.service.ts | 55 ++++++--- .../market-overview/tdx/tdx.mapper.test.ts | 113 ++++++++++++++++- .../api/src/market-overview/tdx/tdx.mapper.ts | 115 +++++++++++++----- server/openapi/openapi.json | 19 ++- web/src/api/generated/schema.ts | 9 +- web/src/styles/index.scss | 16 +++ .../market-overview/components/FundTrend.vue | 42 +++++-- .../components/MarketBreadth.vue | 24 ++-- .../components/MarketDataPanels.test.ts | 77 ++++++++++++ .../components/SectorOverview.vue | 46 ++++--- 20 files changed, 557 insertions(+), 113 deletions(-) diff --git a/docs/features/FEAT-MO-001-market-overview/development-summary.md b/docs/features/FEAT-MO-001-market-overview/development-summary.md index a36ae21..8187251 100644 --- a/docs/features/FEAT-MO-001-market-overview/development-summary.md +++ b/docs/features/FEAT-MO-001-market-overview/development-summary.md @@ -27,6 +27,7 @@ - 对未注册桥接方法返回结构化原因,避免前端显示“实时”或空白数据。 - 修复 Vue TypeScript 单文件组件未被 ESLint 正确解析的问题,并为 E2E 测试进程补齐必需的本机 TDX 配置边界。 - 隔离应用外壳单测中的真实 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`。 - 前端单测、typecheck、build、后端 TDX/映射/服务/HTTP/OpenAPI 测试通过。 - `pnpm verify:e2e`:4/4 通过(桌面和移动端各覆盖页面与健康契约)。 - 浏览器桌面与 390px 移动视口验证无横向溢出,视角与引擎切换可用。 +- Review 首轮在 `ff8ada8` 发现 6 项重要问题;已在当前修复提交中逐项处置,需二次代码 Review 确认。 - 开发 Schema 按用户确认跳过,沿用 `v0.0.10 captured_empty` 追溯边界,不读取生产库。 diff --git a/docs/features/FEAT-MO-001-market-overview/gitnexus/detected-changes.md b/docs/features/FEAT-MO-001-market-overview/gitnexus/detected-changes.md index 540c60b..7af6cf0 100644 --- a/docs/features/FEAT-MO-001-market-overview/gitnexus/detected-changes.md +++ b/docs/features/FEAT-MO-001-market-overview/gitnexus/detected-changes.md @@ -10,10 +10,10 @@ npx gitnexus detect-changes --repo "$PWD" --scope staged ## 结果 - 索引:已完成最终增量更新。 -- 暂存文件:38 个文件、87 个符号(包含完整功能档案)。 -- 影响执行流:11 条,CLI 风险:high。 -- 主要执行流:`GetSummary`、`GetBreadth`、`GetSectors`、`GetFunds` 和 `RunTestApi`。 +- 暂存文件:20 个文件、67 个符号;GitNexus 自动改写的 `AGENTS.md` 已明确排除。 +- 影响执行流:16 条,CLI 风险:critical。 +- 主要执行流:`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 绝对路径执行,没有将工具限制误判为低风险。 diff --git a/docs/features/FEAT-MO-001-market-overview/prd.md b/docs/features/FEAT-MO-001-market-overview/prd.md index 5f95581..419601f 100644 --- a/docs/features/FEAT-MO-001-market-overview/prd.md +++ b/docs/features/FEAT-MO-001-market-overview/prd.md @@ -50,7 +50,7 @@ ## 数据与口径 -首个切片由 Nest API 直接读取 TDX 只读桥接层。指数和摘要使用真实 TDX K 线映射;当前桥接层未提供市场宽度、板块排行和资金趋势方法,因此这些模块返回结构化 `unavailable`,不得用指数或离线样本冒充。所有响应仍包含交易日期、快照时间、来源状态、完整性和单位。 +首个切片由 Nest API 直接读取 TDX 只读桥接层。指数和摘要使用真实 TDX K 线映射;市场宽度使用三市场指数快照,板块使用一级行业目录与日行情,量能使用三市场指数成交额汇总。主力净流入和盘中新闻不在本切片 TDX 主链路中,必须保持明确的补充源边界,不得用指数或离线样本冒充。所有响应仍包含交易日期、快照时间、来源状态、完整性和单位。 A 股使用红涨绿跌。成交额统一展示为亿元,比例明确使用百分比。历史数据和低频资金数据必须显示实际数据日期,旧数据不得标记为实时。 diff --git a/docs/features/FEAT-MO-001-market-overview/qa/visual-qa.md b/docs/features/FEAT-MO-001-market-overview/qa/visual-qa.md index 1c65393..23826cd 100644 --- a/docs/features/FEAT-MO-001-market-overview/qa/visual-qa.md +++ b/docs/features/FEAT-MO-001-market-overview/qa/visual-qa.md @@ -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/混合/对照选择有可见状态。 -- 通过:TDX 来源、更新时间和不可用原因可见,未把不可用模块渲染为空白。 +- 通过:实际切换到“综合”和“混合”后,按钮激活态和下拉选中项同步更新。 +- 通过:TDX 来源、更新时间和不可用原因可见;当前 `127.0.0.1:17710` 未监听时,摘要、指数、宽度、板块和成交额趋势均显示结构化不可用状态,没有空白或伪造实时数据。 +- 通过:移动端浏览器控制台无 `warning` 或 `error`。 ## 残余项 -依据抽屉、指数/板块详情页和真实图表尚未立项,不作为本次首屏视觉验收项。 +依据抽屉、指数/板块详情页和真实图表尚未立项,不作为本次首屏视觉验收项。真实 TDX 数据展示的接口冒烟证据沿用 `tdx-smoke-report.md`;本次隧道未监听,因此没有覆盖或伪造新的真实行情截图。 diff --git a/docs/features/FEAT-MO-001-market-overview/release-notes.md b/docs/features/FEAT-MO-001-market-overview/release-notes.md index 6c556d7..9f295e1 100644 --- a/docs/features/FEAT-MO-001-market-overview/release-notes.md +++ b/docs/features/FEAT-MO-001-market-overview/release-notes.md @@ -4,10 +4,11 @@ - 新增 TDX 直连的市场概览首屏和五个模块 API。 - 支持综合、短线、中长线视角与规则、AI、混合、对照偏好选择。 -- 明确显示 TDX 来源、快照时间、缺失能力和模块级错误。 +- 市场宽度、一级行业强弱和成交额趋势接入真实 TDX 数据,并对空数据、部分数据、日期不一致和延迟状态显示明确边界。 +- 明确显示 TDX 来源、快照时间、交易日期、市场覆盖数和模块级错误。 ## 明确不包含 - 不启用 MySQL、Redis、RocketMQ 或 AI 运行链路。 - 不包含用户体系、详情页、依据抽屉、交易操作和荐股能力。 -- 不包含板块、宽度和资金真实数据,直到 TDX 桥接层提供并验证对应方法。 +- 不包含主力净流入、盘中新闻、真实图表和详情页;这些能力需另立补充数据源和功能需求。 diff --git a/docs/features/FEAT-MO-001-market-overview/review/code-review.md b/docs/features/FEAT-MO-001-market-overview/review/code-review.md index 9f3d3ed..4922383 100644 --- a/docs/features/FEAT-MO-001-market-overview/review/code-review.md +++ b/docs/features/FEAT-MO-001-market-overview/review/code-review.md @@ -1,5 +1,22 @@ # 代码 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 首屏组件和对应测试。 @@ -27,4 +44,8 @@ - 未进行 7×24、断线重连和生产压测;TDX 宽度/板块/资金方法及详情页仍待独立功能。 - ESLint 对 Vue 单文件组件仅保留 35 条既有风格 warning,无 error;格式、类型和构建门禁均通过。 -最终结论:passed +## 二次 Review + +等待修复提交完成后执行独立二次 Review。 + +最终结论:pending diff --git a/docs/features/FEAT-MO-001-market-overview/review/standards-review.md b/docs/features/FEAT-MO-001-market-overview/review/standards-review.md index 60313e9..7943a20 100644 --- a/docs/features/FEAT-MO-001-market-overview/review/standards-review.md +++ b/docs/features/FEAT-MO-001-market-overview/review/standards-review.md @@ -15,4 +15,10 @@ - 开发总结记录原始需求、范围变化、实际完成、修复缺陷、延期项、提交和验证证据。 - QA、发布说明和回退说明与本功能档案保持同一功能编号。 -最终结论:passed +## 首轮问题同步检查 + +- 数据完整性、状态语义、OpenAPI nullable、DTO 输出边界和前端降级提示均已补齐回归测试。 +- PRD、发布说明、测试计划和开发总结已同步实际口径,避免实现与文档分叉。 +- 浏览器复验覆盖桌面与 `390×844` 移动视口;当前 TDX 隧道未监听时只确认结构化降级,历史真实行情冒烟证据保持独立。 + +最终结论:pending(等待独立二次代码 Review 与完整门禁) diff --git a/docs/features/FEAT-MO-001-market-overview/test-plan.md b/docs/features/FEAT-MO-001-market-overview/test-plan.md index 61269ce..e26e56e 100644 --- a/docs/features/FEAT-MO-001-market-overview/test-plan.md +++ b/docs/features/FEAT-MO-001-market-overview/test-plan.md @@ -13,6 +13,8 @@ - 前端 Vitest、typecheck、build:通过。 - 后端 TDX 客户端、映射、服务、HTTP/OpenAPI 测试:通过。 - `curl` 冒烟:`/breadth`、`/sectors`、`/funds` 均返回 `source=tdx,status=fresh`;分别验证 `UpHome/DownHome` 汇总、一级行业排序和成交额单位转换。 +- 异常映射:覆盖畸形涨跌家数字段、空一级行业目录、单根板块 K 线、成交额缺市场/交易日错位/负数,以及三市完整性状态。 +- 前端状态:覆盖宽度、板块和成交额模块的 `partial`/`delayed` 警告、真实交易日期和成交额零变化中性色。 - 浏览器验证:桌面默认视口和 390px 移动视口无横向溢出;综合/短线切换和分析引擎选择可用。 ## 安全、性能与残余风险 diff --git a/server/apps/api/src/market-overview/market-overview.dto.ts b/server/apps/api/src/market-overview/market-overview.dto.ts index 7caf1b1..af7aceb 100644 --- a/server/apps/api/src/market-overview/market-overview.dto.ts +++ b/server/apps/api/src/market-overview/market-overview.dto.ts @@ -72,7 +72,7 @@ export class BreadthDataDto { @ApiProperty({ type: Number }) declining!: number; - @ApiPropertyOptional({ type: Number, nullable: true }) + @ApiProperty({ type: Number, nullable: true }) unchanged!: number | null; } @@ -83,8 +83,8 @@ export class SectorQuoteDto { @ApiProperty({ type: String }) name!: string; - @ApiPropertyOptional({ type: Number, nullable: true }) - changePercent!: number | null; + @ApiProperty({ type: Number }) + changePercent!: number; @ApiProperty({ enum: ['up', 'down', 'flat'] }) direction!: QuoteDirection; @@ -110,6 +110,15 @@ export class FundDataDto { @ApiProperty({ type: String, example: '亿元' }) 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 { diff --git a/server/apps/api/src/market-overview/market-overview.service.test.ts b/server/apps/api/src/market-overview/market-overview.service.test.ts index e376122..d711443 100644 --- a/server/apps/api/src/market-overview/market-overview.service.test.ts +++ b/server/apps/api/src/market-overview/market-overview.service.test.ts @@ -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 () => { const client = { 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 () => { const client = { request: vi.fn().mockResolvedValue(turnoverResult) }; const service = new MarketOverviewService(client); @@ -156,8 +216,26 @@ describe('市场概览服务', () => { previousAmount: 30, changePercent: 20, unit: '亿元', + marketCount: 3, + tradeDate: '20260818', + previousTradeDate: '20260817', }, }); 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 个市场']); + }); }); diff --git a/server/apps/api/src/market-overview/market-overview.service.ts b/server/apps/api/src/market-overview/market-overview.service.ts index eed3f50..d74fdaa 100644 --- a/server/apps/api/src/market-overview/market-overview.service.ts +++ b/server/apps/api/src/market-overview/market-overview.service.ts @@ -112,28 +112,34 @@ export class MarketOverviewService { }), ), ); - const snapshots = results.flatMap((result) => - result.status === 'fulfilled' ? [result.value] : [], - ); - if (!snapshots.length) { + const validSnapshots = results.flatMap((result) => { + 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 + ); + }).map((item) => item.name); + if (!mapped.data) { return { ...meta, status: 'unavailable', data: null, - warnings: [ - this.toWarning( - results[0]?.status === 'rejected' ? results[0].reason : undefined, - '市场宽度', - ), - ], + warnings: [`市场宽度数据不可用:${unavailableMarkets.join('、')}`], }; } - const status = snapshots.length === BREADTH_INDEXES.length ? 'fresh' : 'partial'; + const status = mapped.validCount === BREADTH_INDEXES.length ? 'fresh' : 'partial'; return { ...meta, status, - data: mapBreadthSnapshots(snapshots), - warnings: status === 'partial' ? ['部分市场指数未返回涨跌家数'] : [], + data: mapped.data, + warnings: + status === 'partial' ? [`部分市场涨跌家数不可用:${unavailableMarkets.join('、')}`] : [], }; } @@ -145,6 +151,14 @@ export class MarketOverviewService { list_type: 1, }); 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('get_market_data', { stock_list: definitions.map((item) => item.code), count: 2, @@ -187,10 +201,19 @@ export class MarketOverviewService { dividend_type: 'none', period: '1d', }); - const data = mapTurnoverTrend(result, TURNOVER_INDEXES); - if (!data) + const mapped = mapTurnoverTrend(result, TURNOVER_INDEXES); + if (!mapped.data) 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) { return { ...meta, diff --git a/server/apps/api/src/market-overview/tdx/tdx.mapper.test.ts b/server/apps/api/src/market-overview/tdx/tdx.mapper.test.ts index ef46ae7..6c3541f 100644 --- a/server/apps/api/src/market-overview/tdx/tdx.mapper.test.ts +++ b/server/apps/api/src/market-overview/tdx/tdx.mapper.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { mapBreadthSnapshots, mapIndexQuotes, mapTurnoverTrend } from './tdx.mapper'; +import { + mapBreadthSnapshots, + mapIndexQuotes, + mapSectorQuotes, + mapTurnoverTrend, +} from './tdx.mapper'; describe('TDX 行情映射', () => { it('将 K 线数组转换为 A 股红涨绿跌的指数报价', () => { @@ -62,7 +67,66 @@ describe('TDX 行情映射', () => { { UpHome: '10', DownHome: '5' }, { 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 万元转换为亿元并计算环比', () => { @@ -84,6 +148,49 @@ describe('TDX 行情映射', () => { }, [{ 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' }, + }); }); }); diff --git a/server/apps/api/src/market-overview/tdx/tdx.mapper.ts b/server/apps/api/src/market-overview/tdx/tdx.mapper.ts index 5fa58a8..f1df27a 100644 --- a/server/apps/api/src/market-overview/tdx/tdx.mapper.ts +++ b/server/apps/api/src/market-overview/tdx/tdx.mapper.ts @@ -12,6 +12,11 @@ function toNumber(value: string | undefined): number | 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'] { if (changePercent === null || changePercent === 0) return 'flat'; return changePercent > 0 ? 'up' : 'down'; @@ -52,50 +57,98 @@ export function mapIndexQuotes( }); } -export function mapBreadthSnapshots( - snapshots: readonly { UpHome?: string; DownHome?: string }[], -): BreadthDataDto { - const advancing = snapshots.reduce((total, item) => total + (toNumber(item.UpHome) ?? 0), 0); - const declining = snapshots.reduce((total, item) => total + (toNumber(item.DownHome) ?? 0), 0); - return { advancing, declining, unchanged: null }; +/** TDX 涨跌家数必须成对有效且非负;无效市场整体排除,禁止把缺失字段折算为零。 */ +export function mapBreadthSnapshots(snapshots: readonly { UpHome?: string; DownHome?: string }[]): { + data: BreadthDataDto | null; + validCount: number; +} { + 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( result: TdxMarketDataResult, definitions: readonly IndexDefinition[], ): SectorQuoteDto[] { - return mapIndexQuotes(result, definitions).sort((left, right) => { - return ( - (right.changePercent ?? Number.NEGATIVE_INFINITY) - - (left.changePercent ?? Number.NEGATIVE_INFINITY) - ); - }); + return definitions + .flatMap((definition) => { + const series = result.Value[definition.code]; + 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( result: TdxMarketDataResult, definitions: readonly IndexDefinition[], -): FundDataDto | null { - let currentAmount = 0; - let previousAmount = 0; - let validSeries = 0; - - for (const definition of definitions) { +): { data: FundDataDto | null; validMarketCount: number } { + const candidates = definitions.flatMap((definition) => { const series = result.Value[definition.code]; - if (!series) continue; - const current = toNumber(series.Amount.at(-1)); - const previous = toNumber(series.Amount.at(-2)); - if (current === null || previous === null) continue; - currentAmount += current; - previousAmount += previous; - validSeries += 1; - } + if (!series) return []; + const current = toNonNegativeNumber(series.Amount.at(-1)); + const previous = toNonNegativeNumber(series.Amount.at(-2)); + const tradeDate = series.Date.at(-1); + const previousTradeDate = series.Date.at(-2); + if ( + 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 { - currentAmount: Math.round((currentAmount / 10000) * 100) / 100, - previousAmount: Math.round((previousAmount / 10000) * 100) / 100, - changePercent: Math.round(((currentAmount - previousAmount) / previousAmount) * 10000) / 100, - unit: '亿元', + data: { + currentAmount: Math.round((currentAmount / 10000) * 100) / 100, + previousAmount: Math.round((previousAmount / 10000) * 100) / 100, + changePercent: Math.round(((currentAmount - previousAmount) / previousAmount) * 10000) / 100, + unit: '亿元', + marketCount: aligned.length, + tradeDate: expected.tradeDate, + previousTradeDate: expected.previousTradeDate, + }, + validMarketCount: aligned.length, }; } diff --git a/server/openapi/openapi.json b/server/openapi/openapi.json index 24274cc..3802b9e 100644 --- a/server/openapi/openapi.json +++ b/server/openapi/openapi.json @@ -7,7 +7,7 @@ "declining": { "type": "number" }, "unchanged": { "nullable": true, "type": "number" } }, - "required": ["advancing", "declining"], + "required": ["advancing", "declining", "unchanged"], "type": "object" }, "BreadthResponseDto": { @@ -44,10 +44,21 @@ "properties": { "changePercent": { "type": "number" }, "currentAmount": { "type": "number" }, + "marketCount": { "maximum": 3, "minimum": 1, "type": "number" }, "previousAmount": { "type": "number" }, + "previousTradeDate": { "example": "20260817", "type": "string" }, + "tradeDate": { "example": "20260818", "type": "string" }, "unit": { "example": "亿元", "type": "string" } }, - "required": ["currentAmount", "previousAmount", "changePercent", "unit"], + "required": [ + "currentAmount", + "previousAmount", + "changePercent", + "unit", + "marketCount", + "tradeDate", + "previousTradeDate" + ], "type": "object" }, "FundsResponseDto": { @@ -143,13 +154,13 @@ }, "SectorQuoteDto": { "properties": { - "changePercent": { "nullable": true, "type": "number" }, + "changePercent": { "type": "number" }, "code": { "type": "string" }, "direction": { "enum": ["up", "down", "flat"], "type": "string" }, "name": { "type": "string" }, "tradeDate": { "type": "string" } }, - "required": ["code", "name", "direction", "tradeDate"], + "required": ["code", "name", "changePercent", "direction", "tradeDate"], "type": "object" }, "SectorsResponseDto": { diff --git a/web/src/api/generated/schema.ts b/web/src/api/generated/schema.ts index 144bcdc..abdef5b 100644 --- a/web/src/api/generated/schema.ts +++ b/web/src/api/generated/schema.ts @@ -107,7 +107,7 @@ export interface components { BreadthDataDto: { advancing: number; declining: number; - unchanged?: number | null; + unchanged: number | null; }; BreadthResponseDto: { /** Format: date-time */ @@ -128,7 +128,12 @@ export interface components { FundDataDto: { changePercent: number; currentAmount: number; + marketCount: number; previousAmount: number; + /** @example 20260817 */ + previousTradeDate: string; + /** @example 20260818 */ + tradeDate: string; /** @example 亿元 */ unit: string; }; @@ -184,7 +189,7 @@ export interface components { items: components['schemas']['SectorQuoteDto'][]; }; SectorQuoteDto: { - changePercent?: number | null; + changePercent: number; code: string; /** @enum {string} */ direction: 'up' | 'down' | 'flat'; diff --git a/web/src/styles/index.scss b/web/src/styles/index.scss index 25d93a2..5507aec 100644 --- a/web/src/styles/index.scss +++ b/web/src/styles/index.scss @@ -297,6 +297,18 @@ textarea { 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 { display: grid; gap: 16px; @@ -497,6 +509,10 @@ textarea { color: #19846e; } +.turnover-trend [data-direction='flat'] { + color: #6f7a84; +} + .turnover-trend { display: grid; gap: 12px; diff --git a/web/src/views/market-overview/components/FundTrend.vue b/web/src/views/market-overview/components/FundTrend.vue index 8d715f3..a90de06 100644 --- a/web/src/views/market-overview/components/FundTrend.vue +++ b/web/src/views/market-overview/components/FundTrend.vue @@ -2,6 +2,10 @@ import type { FundsResponse } from '@/types/market-overview'; 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)}` : '—'; +} diff --git a/web/src/views/market-overview/components/MarketBreadth.vue b/web/src/views/market-overview/components/MarketBreadth.vue index 9faab9d..d50d3f8 100644 --- a/web/src/views/market-overview/components/MarketBreadth.vue +++ b/web/src/views/market-overview/components/MarketBreadth.vue @@ -18,17 +18,23 @@ defineProps<{ response: BreadthResponse | null; loading: boolean }>(); 暂不可用 {{ response.warnings[0] }} -
-
- 上涨{{ response.data.advancing }} +
暂无宽度数据
diff --git a/web/src/views/market-overview/components/MarketDataPanels.test.ts b/web/src/views/market-overview/components/MarketDataPanels.test.ts index d720fa9..73c0aa1 100644 --- a/web/src/views/market-overview/components/MarketDataPanels.test.ts +++ b/web/src/views/market-overview/components/MarketDataPanels.test.ts @@ -54,6 +54,52 @@ describe('市场概览真实数据面板', () => { 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('资金模块明确展示成交额和与上一交易日的变化', () => { const wrapper = mount(FundTrend, { props: { @@ -65,6 +111,9 @@ describe('市场概览真实数据面板', () => { previousAmount: 11000, changePercent: 12.23, unit: '亿元' as const, + marketCount: 3, + tradeDate: '20260818', + previousTradeDate: '20260817', }, }, }, @@ -75,4 +124,32 @@ describe('市场概览真实数据面板', () => { expect(wrapper.text()).toContain('较前一交易日 +12.23%'); 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'); + }); }); diff --git a/web/src/views/market-overview/components/SectorOverview.vue b/web/src/views/market-overview/components/SectorOverview.vue index 3cbc747..2a13847 100644 --- a/web/src/views/market-overview/components/SectorOverview.vue +++ b/web/src/views/market-overview/components/SectorOverview.vue @@ -3,6 +3,10 @@ import type { SectorsResponse } from '@/types/market-overview'; defineProps<{ response: SectorsResponse | null; loading: boolean }>(); 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)}` : '—'; +}