fix(FEAT-MO-001): 校验TDX交易日期语义

This commit is contained in:
2026-08-18 20:48:20 +08:00
parent 7e4dcd9232
commit ae2a7e08ed
12 changed files with 180 additions and 70 deletions
@@ -8,7 +8,7 @@
- `breadth`:使用 TDX 指数快照的 `UpHome`、`DownHome` 汇总上证、深证和北证市场宽度;未返回的 `unchanged` 明确为 `null`,不得用总数推算。 - `breadth`:使用 TDX 指数快照的 `UpHome`、`DownHome` 汇总上证、深证和北证市场宽度;未返回的 `unchanged` 明确为 `null`,不得用总数推算。
- `sectors`:使用 TDX 系统分类中的一级行业(`get_stock_list(market=16)`)和批量日行情,按涨跌幅排序后展示重点强弱板块。 - `sectors`:使用 TDX 系统分类中的一级行业(`get_stock_list(market=16)`)和批量日行情,按涨跌幅排序后展示重点强弱板块。
- `funds`:模块名称和口径为“成交额趋势”,使用核心交易所指数日行情的 `Amount` 汇总当前与前一交易日成交额;这不是主力净流入。 - `funds`:模块名称和口径为“成交额趋势”,使用核心交易所指数日行情的 `Amount` 汇总当前与前一交易日成交额;这不是主力净流入。
- 交易日对齐:指数和一级行业只保留最新交易日截面;成交额按最新有效交易日期对汇总,避免数组顺序或旧市场覆盖新数据。市场宽度额外并行读取一个核心指数日线作为日期参考。 - 交易日对齐:交易日期必须是有效 `YYYYMMDD` 公历日期;指数和一级行业只保留最新交易日截面。成交额还要求日期与金额数组等长且前一交易日早于当前交易日,再按最新有效日期对汇总,避免畸形日期、数组错位或旧市场覆盖新数据。市场宽度额外并行读取一个核心指数日线作为日期参考。
- 新鲜度:日行情交易日期与 `Asia/Shanghai` 当前日历日期不同即标记 `delayed`;宽度沿用参考指数日期。首版不引入交易日历依赖,休市日保守标延迟并展示实际日期。 - 新鲜度:日行情交易日期与 `Asia/Shanghai` 当前日历日期不同即标记 `delayed`;宽度沿用参考指数日期。首版不引入交易日历依赖,休市日保守标延迟并展示实际日期。
- 主力净流入和盘中新闻不在本次 TDX 首屏主链路中,后续通过独立补充数据源适配器接入,并在响应中标记来源与数据质量。 - 主力净流入和盘中新闻不在本次 TDX 首屏主链路中,后续通过独立补充数据源适配器接入,并在响应中标记来源与数据质量。
- 页面不把缺失或权限不足的指标降级为模拟值;单个来源失败只影响对应模块。 - 页面不把缺失或权限不足的指标降级为模拟值;单个来源失败只影响对应模块。
@@ -29,6 +29,7 @@
- 隔离应用外壳单测中的真实 Axios 请求,消除 JSDOM 网络错误噪声,保持页面测试只验证外壳职责。 - 隔离应用外壳单测中的真实 Axios 请求,消除 JSDOM 网络错误噪声,保持页面测试只验证外壳职责。
- 根据代码 Review 修复畸形 TDX 字段被折算为零、缺市场成交额误报三市、交易日错位跨日求和、空行业目录误报 `fresh`、前端隐藏 `partial/delayed` 警告和零变化使用上涨色等缺陷,并补齐对应回归测试。 - 根据代码 Review 修复畸形 TDX 字段被折算为零、缺市场成交额误报三市、交易日错位跨日求和、空行业目录误报 `fresh`、前端隐藏 `partial/delayed` 警告和零变化使用上涨色等缺陷,并补齐对应回归测试。
- 根据二次 Review 继续修复涨跌家数接受非整数、成交额受市场定义顺序影响、指数/板块跨交易日混排和后端从不生成 `delayed` 的边界;宽度增加核心指数日期参考,不新增基础设施或第三方依赖。 - 根据二次 Review 继续修复涨跌家数接受非整数、成交额受市场定义顺序影响、指数/板块跨交易日混排和后端从不生成 `delayed` 的边界;宽度增加核心指数日期参考,不新增基础设施或第三方依赖。
- 根据第三轮独立 Review 修复 8 位畸形日期被误认为有效公历日期、成交额接受倒序日期对与日期/金额数组错位,以及服务测试依赖运行当天的问题。
## 未完成与延期项 ## 未完成与延期项
@@ -38,16 +39,16 @@
## 关键提交 ## 关键提交
- `9a965fa`、`d283ee3`、`6eb34b5`、`c2fa929`、`15a2500`、`27bc3c1`、`8261948`、`ff8ada8`、`49f6be9` - `9a965fa`、`d283ee3`、`6eb34b5`、`c2fa929`、`15a2500`、`27bc3c1`、`8261948`、`ff8ada8`、`49f6be9`、`7e4dcd9`
## 验证证据 ## 验证证据
- GitNexus 已完成提交前重新索引;CLI `detect-changes --scope staged` 映射 16 条概览执行流并标记 critical。风险来自本功能修改核心概览服务、TDX 映射和前端数据面板,已由定向单测、HTTP/OpenAPI、前端状态回归、构建、浏览器视觉验证和 E2E 覆盖;未发现额外业务模块调用方。当前环境未暴露 GitNexus MCP 变更检测工具,因此使用同版本 CLI 并指定 worktree 绝对路径执行。 - GitNexus 已完成提交前重新索引;上一修复提交的最终 staged 检测映射 66 个符号、21 条概览执行流并标记 critical。风险来自本功能修改核心概览服务、TDX 映射和前端数据面板,已由定向单测、HTTP/OpenAPI、前端状态回归、构建、浏览器视觉验证和 E2E 覆盖;未发现额外业务模块调用方。第三轮修复前又用 MCP 对 `toTradeDate` 和 `mapTurnoverTrend` 执行符号级影响分析,直接调用方仅在市场概览映射与测试范围。
- API 冒烟:`/breadth`、`/sectors`、`/funds` 均返回 `source=tdx,status=fresh`;完整结果和耗时见 `tdx-smoke-report.md`。 - API 冒烟:`/breadth`、`/sectors`、`/funds` 均返回 `source=tdx,status=fresh`;完整结果和耗时见 `tdx-smoke-report.md`。
- 家庭 LAN 隧道二次复验:五个市场概览接口均为 `fresh`,指数 3 项、板块 8 项、成交额覆盖 3 个市场且交易日期完整;只保存脱敏统计。 - 家庭 LAN 隧道二次复验:五个市场概览接口均为 `fresh`,指数 3 项、板块 8 项、成交额覆盖 3 个市场且交易日期完整;只保存脱敏统计。
- 日期对齐与延迟状态修复后,后端 60/60 和家庭 LAN 五接口当日 `fresh` 复验通过;完整全仓门禁等待本修复提交推送后再次执行。 - 日期对齐与延迟状态修复后,后端 60/60、完整全仓门禁和家庭 LAN 五接口当日 `fresh` 复验通过;第三轮日期真实性与测试时钟修复后,后端增加至 63/63,完整全仓门禁等待本修复提交推送后再次执行。
- 前端单测、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 确认。 - 三轮独立 Review 均已归档:前两轮数据完整性与状态问题已关闭;第三轮发现的日期真实性/顺序和测试时钟问题已按 TDD 修复,等待再次独立确认。
- 开发 Schema 按用户确认跳过,沿用 `v0.0.10 captured_empty` 追溯边界,不读取生产库。 - 开发 Schema 按用户确认跳过,沿用 `v0.0.10 captured_empty` 追溯边界,不读取生产库。
@@ -10,10 +10,10 @@ npx gitnexus detect-changes --repo "$PWD" --scope staged
## 结果 ## 结果
- 索引:已完成最终增量更新。 - 索引:已完成最终增量更新。
- 暂存文件:15 个文件、66 个符号;GitNexus 自动改写的 `AGENTS.md` 已明确排除。 - 暂存文件:12 个文件、31 个符号;GitNexus 自动改写的 `AGENTS.md` 已明确排除。
- 影响执行流:21 条,CLI 风险:critical。 - 影响执行流:2 条,CLI 风险:medium。
- 主要执行流:`GetSummary`、`GetIndices`、`GetBreadth`、`GetSectors` 和 `GetFunds`,以及对应映射和状态创建流程。 - 主要执行流:`Candidates → ToTradeDate` 与 `GetFunds → MapTurnoverTrend`;本轮未修改服务状态策略或跨模块契约。
## 处置 ## 处置
critical 来自本功能修改市场概览核心服务、TDX 映射和三个前端数据面板,不是未知跨业务扩散。已用 TDX 映射/服务/HTTP/OpenAPI 单测、前端状态回归、边界测试、构建、浏览器视觉验证和 4/4 E2E 覆盖;未发现用户、消息、数据库、Redis、RocketMQ、Scheduler、Worker、认证或部署调用方。当前环境未暴露 GitNexus MCP 变更检测工具,已使用同版本 CLI 并指定 worktree 绝对路径执行,没有将工具限制误判为低风险。 本轮 medium 来自日期解析和成交额候选过滤的局部映射变更,不是未知跨业务扩散。已用 TDX 映射/服务测试、类型检查和 `git diff --check` 验证;前一提交已覆盖 HTTP/OpenAPI、前端状态、构建、浏览器视觉和 4/4 E2E,未发现用户、消息、数据库、Redis、RocketMQ、Scheduler、Worker、认证或部署调用方。当前环境已使用 GitNexus MCP 与同版本 CLI 交叉确认,没有将工具限制误判为低风险。
@@ -54,6 +54,8 @@
A 股使用红涨绿跌。成交额统一展示为亿元,比例明确使用百分比。历史数据和低频资金数据必须显示实际数据日期,旧数据不得标记为实时。 A 股使用红涨绿跌。成交额统一展示为亿元,比例明确使用百分比。历史数据和低频资金数据必须显示实际数据日期,旧数据不得标记为实时。
TDX 交易日期必须通过真实公历日期校验。成交额当前/前一日金额必须与同索引日期对齐,且前一交易日严格早于当前交易日;不满足条件的市场不参与汇总,也不得抢占最新日期截面。
首个切片不引入交易日历依赖:指数、一级行业和成交额的最新交易日期与 `Asia/Shanghai` 当前日历日期不同即标记 `delayed`;市场宽度快照缺少日期字段,使用同批核心指数日线作为日期参考。周末和法定休市日会保守显示延迟并同时展示实际交易日期,后续接入权威交易日历后再细化“最新交易日”语义。 首个切片不引入交易日历依赖:指数、一级行业和成交额的最新交易日期与 `Asia/Shanghai` 当前日历日期不同即标记 `delayed`;市场宽度快照缺少日期字段,使用同批核心指数日线作为日期参考。周末和法定休市日会保守显示延迟并同时展示实际交易日期,后续接入权威交易日历后再细化“最新交易日”语义。
## 状态与异常 ## 状态与异常
@@ -3,7 +3,7 @@
## 自动化结果 ## 自动化结果
- 前端 Vitest:8 个文件、20 个测试通过。 - 前端 Vitest:8 个文件、20 个测试通过。
- 后端 Vitest:11 个文件、60 个测试通过;API 启动器 Node 测试 2/2 通过,Schema、治理、边界和部署门禁通过。 - 后端 Vitest:11 个文件、63 个测试通过;API 启动器 Node 测试 2/2 通过,Schema、治理、边界和部署门禁通过。
- `pnpm verify:e2e`:桌面/移动端页面和健康契约共 4 个测试通过。 - `pnpm verify:e2e`:桌面/移动端页面和健康契约共 4 个测试通过。
- `pnpm typecheck`、`pnpm build`、`pnpm format:check`:通过。 - `pnpm typecheck`、`pnpm build`、`pnpm format:check`:通过。
- ESLint:0 error,保留 35 条 Vue 风格 warning,不阻断门禁。 - ESLint:0 error,保留 35 条 Vue 风格 warning,不阻断门禁。
@@ -14,4 +14,5 @@
- TDX 隧道未监听时,五个模块均返回明确不可用状态,未伪造指标;切换到家庭 LAN 隧道后,`summary`、`indices`、`breadth`、`sectors`、`funds` 均返回 `source=tdx,status=fresh`。 - TDX 隧道未监听时,五个模块均返回明确不可用状态,未伪造指标;切换到家庭 LAN 隧道后,`summary`、`indices`、`breadth`、`sectors`、`funds` 均返回 `source=tdx,status=fresh`。
- 家庭 LAN 真实冒烟中,指数返回 3 项、板块返回 8 项、成交额覆盖 3 个市场且交易日期完整;未保存真实价格或成交额数值。 - 家庭 LAN 真实冒烟中,指数返回 3 项、板块返回 8 项、成交额覆盖 3 个市场且交易日期完整;未保存真实价格或成交额数值。
- 修复日期对齐和延迟状态后再次通过家庭 LAN 真实冒烟,五个接口在当日数据下仍为 `fresh` 且无 warning。 - 修复日期对齐和延迟状态后再次通过家庭 LAN 真实冒烟,五个接口在当日数据下仍为 `fresh` 且无 warning。
- 第三轮 Review 修复后,非法月份/日期、倒序日期对和日期/金额数组错位均被映射层排除;服务测试统一冻结时钟,不依赖执行当天。
- 桌面默认视口与 390px 移动视口无横向溢出;视角和引擎切换可用。 - 桌面默认视口与 390px 移动视口无横向溢出;视角和引擎切换可用。
@@ -6,6 +6,7 @@
- 支持综合、短线、中长线视角与规则、AI、混合、对照偏好选择。 - 支持综合、短线、中长线视角与规则、AI、混合、对照偏好选择。
- 市场宽度、一级行业强弱和成交额趋势接入真实 TDX 数据,并对空数据、部分数据、日期不一致和延迟状态显示明确边界。 - 市场宽度、一级行业强弱和成交额趋势接入真实 TDX 数据,并对空数据、部分数据、日期不一致和延迟状态显示明确边界。
- 指数和板块按最新交易日截面对齐,成交额按最新有效交易日期对汇总;后端根据上海当前日历日期实际生成 `delayed`,市场宽度使用同批核心指数日期参考。 - 指数和板块按最新交易日截面对齐,成交额按最新有效交易日期对汇总;后端根据上海当前日历日期实际生成 `delayed`,市场宽度使用同批核心指数日期参考。
- 拒绝非法公历日期、倒序交易日期以及日期/金额数组错位的 TDX 数据,避免异常市场抢占最新截面或产生时间倒置的成交额趋势。
- 明确显示 TDX 来源、快照时间、交易日期、市场覆盖数和模块级错误。 - 明确显示 TDX 来源、快照时间、交易日期、市场覆盖数和模块级错误。
## 明确不包含 ## 明确不包含
@@ -54,4 +54,14 @@
上述问题已按 TDD 增加失败用例后修复:宽度只接受十进制非负安全整数;指数/板块只保留最新交易日;成交额选择最新有效日期对;四个后端日行情路径均具备 `delayed` 判定,宽度使用同批指数日期参考。修复后等待再次独立 Review。 上述问题已按 TDD 增加失败用例后修复:宽度只接受十进制非负安全整数;指数/板块只保留最新交易日;成交额选择最新有效日期对;四个后端日行情路径均具备 `delayed` 判定,宽度使用同批指数日期参考。修复后等待再次独立 Review。
## 第三轮独立 Review
- 审查范围:`49f6be9..7e4dcd9`
- 结论:`Ready to merge: No`
- Critical:无。
- Important:8 位数字日期未验证真实公历语义,成交额未强制前一交易日早于当前日且未验证日期/金额数组对齐;服务测试固定使用 `20260818` 行情却没有统一冻结系统时间,会在自然日期变化后失败。
- Minor:开发总结未包含 `7e4dcd9`,GitNexus 仍保留上一轮 16 条执行流口径。
处置:按 TDD 先得到 3 个预期失败,再增加真实公历日期、日期顺序和数组对齐校验;服务测试套件统一冻结上海日期,并由单个延迟用例显式切换。修复后后端 Vitest `63/63`、启动器 `2/2` 和类型检查通过,等待再次独立 Review。
最终结论:pending 最终结论:pending
@@ -22,5 +22,6 @@
- 浏览器复验覆盖桌面与 `390×844` 移动视口;当前 TDX 隧道未监听时只确认结构化降级,历史真实行情冒烟证据保持独立。 - 浏览器复验覆盖桌面与 `390×844` 移动视口;当前 TDX 隧道未监听时只确认结构化降级,历史真实行情冒烟证据保持独立。
- 二次独立 Review 发现的严格整数、日期对齐和真实 `delayed` 语义已按 TDD 修复;后端新增 9 个断言并完成家庭 LAN 当日行情复验。 - 二次独立 Review 发现的严格整数、日期对齐和真实 `delayed` 语义已按 TDD 修复;后端新增 9 个断言并完成家庭 LAN 当日行情复验。
- 新鲜度暂按上海当前日历日期保守判断,周末和法定休市日可能显示 `delayed`;该残余边界已同步 PRD、设计和测试计划,不伪装成交易日历已接入。 - 新鲜度暂按上海当前日历日期保守判断,周末和法定休市日可能显示 `delayed`;该残余边界已同步 PRD、设计和测试计划,不伪装成交易日历已接入。
- 第三轮 Review 的日期真实性、先后顺序、数组对齐和测试时钟问题已按 TDD 修复;需求、设计、测试计划、测试结果、发布说明和开发总结已同步。
最终结论:pending(等待独立二次代码 Review 与完整门禁) 最终结论:pending(等待再次独立代码 Review 与完整门禁)
@@ -13,8 +13,9 @@
- 前端 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 线、指数/板块混合交易日、成交额缺市场/交易日错位/负数/首市场落后,以及三市完整性状态。 - 异常映射:覆盖畸形涨跌家数字段(小数、指数、十六进制和超出安全整数)、非法公历日期、空一级行业目录、单根板块 K 线、指数/板块混合交易日、成交额缺市场/交易日倒序/日期金额数组错位/负数/首市场落后,以及三市完整性状态。
- 后端状态:覆盖指数、市场宽度、一级行业和成交额旧交易日期的真实 `delayed` 分支;休市日保守延迟属于已记录边界。 - 后端状态:覆盖指数、市场宽度、一级行业和成交额旧交易日期的真实 `delayed` 分支;休市日保守延迟属于已记录边界。
- 测试时钟:市场概览服务套件统一冻结到上海 `2026-08-18`,单个 `delayed` 用例显式切换目标日期并在用例后恢复,保证测试不会随自然日期变化。
- 前端状态:覆盖宽度、板块和成交额模块的 `partial`/`delayed` 警告、真实交易日期和成交额零变化中性色。 - 前端状态:覆盖宽度、板块和成交额模块的 `partial`/`delayed` 警告、真实交易日期和成交额零变化中性色。
- 浏览器验证:桌面默认视口和 390px 移动视口无横向溢出;综合/短线切换和分析引擎选择可用。 - 浏览器验证:桌面默认视口和 390px 移动视口无横向溢出;综合/短线切换和分析引擎选择可用。
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MarketOverviewService } from './market-overview.service'; import { MarketOverviewService } from './market-overview.service';
@@ -77,6 +77,15 @@ const turnoverResult = {
}; };
describe('市场概览服务', () => { describe('市场概览服务', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-08-18T03:00:00.000Z'));
});
afterEach(() => {
vi.useRealTimers();
});
it('读取指数并返回统一快照元数据', async () => { it('读取指数并返回统一快照元数据', async () => {
const client = { request: vi.fn().mockResolvedValue(indexResult) }; const client = { request: vi.fn().mockResolvedValue(indexResult) };
const service = new MarketOverviewService(client); const service = new MarketOverviewService(client);
@@ -96,19 +105,14 @@ describe('市场概览服务', () => {
}); });
it('核心指数交易日旧于上海当前日期时标记 delayed', async () => { it('核心指数交易日旧于上海当前日期时标记 delayed', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-08-19T03:00:00.000Z')); vi.setSystemTime(new Date('2026-08-19T03:00:00.000Z'));
try { const client = { request: vi.fn().mockResolvedValue(indexResult) };
const client = { request: vi.fn().mockResolvedValue(indexResult) }; const service = new MarketOverviewService(client);
const service = new MarketOverviewService(client);
const response = await service.getIndices(); const response = await service.getIndices();
expect(response.status).toBe('delayed'); expect(response.status).toBe('delayed');
expect(response.warnings).toEqual(['核心指数交易日期为 20260818,非当前日期']); expect(response.warnings).toEqual(['核心指数交易日期为 20260818,非当前日期']);
} finally {
vi.useRealTimers();
}
}); });
it('汇总三个市场指数的上涨和下跌家数并保留未知平盘数', async () => { it('汇总三个市场指数的上涨和下跌家数并保留未知平盘数', async () => {
@@ -157,28 +161,22 @@ describe('市场概览服务', () => {
}); });
it('市场宽度使用核心指数交易日识别 delayed,不把旧快照标为 fresh', async () => { it('市场宽度使用核心指数交易日识别 delayed,不把旧快照标为 fresh', async () => {
vi.useFakeTimers(); const staleIndexResult = structuredClone(indexResult);
vi.setSystemTime(new Date('2026-08-18T03:00:00.000Z')); staleIndexResult.Value['000001.SH'].Date = ['20260816', '20260817'];
try { const client = {
const staleIndexResult = structuredClone(indexResult); request: vi
staleIndexResult.Value['000001.SH'].Date = ['20260816', '20260817']; .fn()
const client = { .mockResolvedValueOnce(breadthSnapshot('10', '5'))
request: vi .mockResolvedValueOnce(breadthSnapshot('20', '15'))
.fn() .mockResolvedValueOnce(breadthSnapshot('3', '2'))
.mockResolvedValueOnce(breadthSnapshot('10', '5')) .mockResolvedValueOnce(staleIndexResult),
.mockResolvedValueOnce(breadthSnapshot('20', '15')) };
.mockResolvedValueOnce(breadthSnapshot('3', '2')) const service = new MarketOverviewService(client);
.mockResolvedValueOnce(staleIndexResult),
};
const service = new MarketOverviewService(client);
const response = await service.getBreadth(); const response = await service.getBreadth();
expect(response.status).toBe('delayed'); expect(response.status).toBe('delayed');
expect(response.warnings).toEqual(['市场宽度交易日期为 20260817,非当前日期']); expect(response.warnings).toEqual(['市场宽度交易日期为 20260817,非当前日期']);
} finally {
vi.useRealTimers();
}
}); });
it('按一级行业涨跌幅排序板块并保留 TDX 代码', async () => { it('按一级行业涨跌幅排序板块并保留 TDX 代码', async () => {
@@ -263,24 +261,16 @@ describe('市场概览服务', () => {
}); });
it('一级行业交易日旧于上海当前日期时标记 delayed', async () => { it('一级行业交易日旧于上海当前日期时标记 delayed', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-08-19T03:00:00.000Z')); vi.setSystemTime(new Date('2026-08-19T03:00:00.000Z'));
try { const client = {
const client = { request: vi.fn().mockResolvedValueOnce(sectorResult).mockResolvedValueOnce(sectorKlineResult),
request: vi };
.fn() const service = new MarketOverviewService(client);
.mockResolvedValueOnce(sectorResult)
.mockResolvedValueOnce(sectorKlineResult),
};
const service = new MarketOverviewService(client);
const response = await service.getSectors(); const response = await service.getSectors();
expect(response.status).toBe('delayed'); expect(response.status).toBe('delayed');
expect(response.warnings).toEqual(['一级行业交易日期为 20260818,非当前日期']); expect(response.warnings).toEqual(['一级行业交易日期为 20260818,非当前日期']);
} finally {
vi.useRealTimers();
}
}); });
it('按交易所指数成交额计算亿元级趋势而不宣称主力净流入', async () => { it('按交易所指数成交额计算亿元级趋势而不宣称主力净流入', async () => {
@@ -321,18 +311,13 @@ describe('市场概览服务', () => {
}); });
it('成交额交易日旧于上海当前日期时标记 delayed', async () => { it('成交额交易日旧于上海当前日期时标记 delayed', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-08-19T03:00:00.000Z')); vi.setSystemTime(new Date('2026-08-19T03:00:00.000Z'));
try { const client = { request: vi.fn().mockResolvedValue(turnoverResult) };
const client = { request: vi.fn().mockResolvedValue(turnoverResult) }; const service = new MarketOverviewService(client);
const service = new MarketOverviewService(client);
const response = await service.getFunds(); const response = await service.getFunds();
expect(response.status).toBe('delayed'); expect(response.status).toBe('delayed');
expect(response.warnings).toEqual(['成交额交易日期为 20260818,非当前日期']); expect(response.warnings).toEqual(['成交额交易日期为 20260818,非当前日期']);
} finally {
vi.useRealTimers();
}
}); });
}); });
@@ -136,6 +136,34 @@ describe('TDX 行情映射', () => {
expect(result).toEqual([expect.objectContaining({ code: '399001.SZ', tradeDate: '20260819' })]); expect(result).toEqual([expect.objectContaining({ code: '399001.SZ', tradeDate: '20260819' })]);
}); });
it('核心指数拒绝不存在的公历日期,避免畸形日期抢占最新截面', () => {
const series = (dates: string[], close: string[]) => ({
Date: dates,
Open: close,
High: close,
Low: close,
Close: close,
Amount: ['1', '1'],
Volume: ['1', '1'],
});
expect(
mapIndexQuotes(
{
ErrorId: '0',
Value: {
'000001.SH': series(['20260817', '20260818'], ['100', '102']),
'399001.SZ': series(['20269898', '20269999'], ['100', '101']),
},
},
[
{ code: '000001.SH', name: '有效日期指数' },
{ code: '399001.SZ', name: '畸形日期指数' },
],
),
).toEqual([expect.objectContaining({ code: '000001.SH', tradeDate: '20260818' })]);
});
it('板块只保留有两个交易日和有效涨跌幅的精确字段', () => { it('板块只保留有两个交易日和有效涨跌幅的精确字段', () => {
const result = mapSectorQuotes( const result = mapSectorQuotes(
{ {
@@ -309,4 +337,69 @@ describe('TDX 行情映射', () => {
validMarketCount: 2, validMarketCount: 2,
}); });
}); });
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'],
});
expect(
mapTurnoverTrend(
{
ErrorId: '0',
Value: {
'000001.SH': series(['20260817', '20260818'], ['100000', '120000']),
'399001.SZ': series(['20260817', '20260818'], ['200000', '240000']),
'899050.BJ': series(['20260820', '20260819'], ['300000', '360000']),
'899051.BJ': series(['20269898', '20269999'], ['300000', '360000']),
},
},
[
{ code: '000001.SH', name: '上证市场' },
{ code: '399001.SZ', name: '深证市场' },
{ code: '899050.BJ', name: '日期倒序市场' },
{ code: '899051.BJ', name: '非法日期市场' },
],
),
).toEqual({
data: {
currentAmount: 36,
previousAmount: 30,
changePercent: 20,
unit: '亿元',
marketCount: 2,
tradeDate: '20260818',
previousTradeDate: '20260817',
},
validMarketCount: 2,
});
});
it('成交额拒绝日期与金额数组错位的市场', () => {
expect(
mapTurnoverTrend(
{
ErrorId: '0',
Value: {
'000001.SH': {
Date: ['20260816', '20260817', '20260818'],
Open: ['1', '1', '1'],
High: ['1', '1', '1'],
Low: ['1', '1', '1'],
Close: ['1', '1', '1'],
Amount: ['100000', '120000'],
Volume: ['1', '1', '1'],
},
},
},
[{ code: '000001.SH', name: '数组错位市场' }],
),
).toEqual({ data: null, validMarketCount: 0 });
});
}); });
@@ -25,7 +25,21 @@ function toNonNegativeInteger(value: string | undefined): number | null {
} }
function toTradeDate(value: string | undefined): string | null { function toTradeDate(value: string | undefined): string | null {
return value && /^\d{8}$/.test(value) ? value : null; if (!value || !/^\d{8}$/.test(value)) return null;
const year = Number(value.slice(0, 4));
const month = Number(value.slice(4, 6));
const day = Number(value.slice(6, 8));
const date = new Date(0);
date.setUTCHours(0, 0, 0, 0);
date.setUTCFullYear(year, month - 1, day);
return year >= 1000 &&
date.getUTCFullYear() === year &&
date.getUTCMonth() === month - 1 &&
date.getUTCDate() === day
? value
: null;
} }
function latestTradeDate<T extends { tradeDate: string }>(items: readonly T[]): string | null { function latestTradeDate<T extends { tradeDate: string }>(items: readonly T[]): string | null {
@@ -133,6 +147,7 @@ export function mapTurnoverTrend(
const candidates = definitions.flatMap((definition) => { const candidates = definitions.flatMap((definition) => {
const series = result.Value[definition.code]; const series = result.Value[definition.code];
if (!series) return []; if (!series) return [];
if (series.Date.length < 2 || series.Date.length !== series.Amount.length) return [];
const current = toNonNegativeNumber(series.Amount.at(-1)); const current = toNonNegativeNumber(series.Amount.at(-1));
const previous = toNonNegativeNumber(series.Amount.at(-2)); const previous = toNonNegativeNumber(series.Amount.at(-2));
const tradeDate = toTradeDate(series.Date.at(-1)); const tradeDate = toTradeDate(series.Date.at(-1));
@@ -143,7 +158,7 @@ export function mapTurnoverTrend(
previous === 0 || previous === 0 ||
!tradeDate || !tradeDate ||
!previousTradeDate || !previousTradeDate ||
tradeDate === previousTradeDate previousTradeDate >= tradeDate
) { ) {
return []; return [];
} }