fix(FEAT-MO-001): 修复交易日对齐与延迟状态
This commit is contained in:
@@ -8,6 +8,8 @@
|
|||||||
- `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` 汇总当前与前一交易日成交额;这不是主力净流入。
|
||||||
|
- 交易日对齐:指数和一级行业只保留最新交易日截面;成交额按最新有效交易日期对汇总,避免数组顺序或旧市场覆盖新数据。市场宽度额外并行读取一个核心指数日线作为日期参考。
|
||||||
|
- 新鲜度:日行情交易日期与 `Asia/Shanghai` 当前日历日期不同即标记 `delayed`;宽度沿用参考指数日期。首版不引入交易日历依赖,休市日保守标延迟并展示实际日期。
|
||||||
- 主力净流入和盘中新闻不在本次 TDX 首屏主链路中,后续通过独立补充数据源适配器接入,并在响应中标记来源与数据质量。
|
- 主力净流入和盘中新闻不在本次 TDX 首屏主链路中,后续通过独立补充数据源适配器接入,并在响应中标记来源与数据质量。
|
||||||
- 页面不把缺失或权限不足的指标降级为模拟值;单个来源失败只影响对应模块。
|
- 页面不把缺失或权限不足的指标降级为模拟值;单个来源失败只影响对应模块。
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
- 修复 Vue TypeScript 单文件组件未被 ESLint 正确解析的问题,并为 E2E 测试进程补齐必需的本机 TDX 配置边界。
|
- 修复 Vue TypeScript 单文件组件未被 ESLint 正确解析的问题,并为 E2E 测试进程补齐必需的本机 TDX 配置边界。
|
||||||
- 隔离应用外壳单测中的真实 Axios 请求,消除 JSDOM 网络错误噪声,保持页面测试只验证外壳职责。
|
- 隔离应用外壳单测中的真实 Axios 请求,消除 JSDOM 网络错误噪声,保持页面测试只验证外壳职责。
|
||||||
- 根据代码 Review 修复畸形 TDX 字段被折算为零、缺市场成交额误报三市、交易日错位跨日求和、空行业目录误报 `fresh`、前端隐藏 `partial/delayed` 警告和零变化使用上涨色等缺陷,并补齐对应回归测试。
|
- 根据代码 Review 修复畸形 TDX 字段被折算为零、缺市场成交额误报三市、交易日错位跨日求和、空行业目录误报 `fresh`、前端隐藏 `partial/delayed` 警告和零变化使用上涨色等缺陷,并补齐对应回归测试。
|
||||||
|
- 根据二次 Review 继续修复涨跌家数接受非整数、成交额受市场定义顺序影响、指数/板块跨交易日混排和后端从不生成 `delayed` 的边界;宽度增加核心指数日期参考,不新增基础设施或第三方依赖。
|
||||||
|
|
||||||
## 未完成与延期项
|
## 未完成与延期项
|
||||||
|
|
||||||
@@ -37,13 +38,14 @@
|
|||||||
|
|
||||||
## 关键提交
|
## 关键提交
|
||||||
|
|
||||||
- `9a965fa`、`d283ee3`、`6eb34b5`、`c2fa929`、`15a2500`、`27bc3c1`
|
- `9a965fa`、`d283ee3`、`6eb34b5`、`c2fa929`、`15a2500`、`27bc3c1`、`8261948`、`ff8ada8`、`49f6be9`
|
||||||
- 首屏 UI 收口提交待 Review 通过后补充。
|
|
||||||
|
|
||||||
## 验证证据
|
## 验证证据
|
||||||
|
|
||||||
- GitNexus 已完成提交前重新索引;CLI `detect-changes --scope staged` 映射 16 条概览执行流并标记 critical。风险来自本功能修改核心概览服务、TDX 映射和前端数据面板,已由定向单测、HTTP/OpenAPI、前端状态回归、构建、浏览器视觉验证和 E2E 覆盖;未发现额外业务模块调用方。当前环境未暴露 GitNexus MCP 变更检测工具,因此使用同版本 CLI 并指定 worktree 绝对路径执行。
|
- 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`。
|
||||||
|
- 家庭 LAN 隧道二次复验:五个市场概览接口均为 `fresh`,指数 3 项、板块 8 项、成交额覆盖 3 个市场且交易日期完整;只保存脱敏统计。
|
||||||
|
- 日期对齐与延迟状态修复后,后端 60/60 和家庭 LAN 五接口当日 `fresh` 复验通过;完整全仓门禁等待本修复提交推送后再次执行。
|
||||||
- 前端单测、typecheck、build、后端 TDX/映射/服务/HTTP/OpenAPI 测试通过。
|
- 前端单测、typecheck、build、后端 TDX/映射/服务/HTTP/OpenAPI 测试通过。
|
||||||
- `pnpm verify:e2e`:4/4 通过(桌面和移动端各覆盖页面与健康契约)。
|
- `pnpm verify:e2e`:4/4 通过(桌面和移动端各覆盖页面与健康契约)。
|
||||||
- 浏览器桌面与 390px 移动视口验证无横向溢出,视角与引擎切换可用。
|
- 浏览器桌面与 390px 移动视口验证无横向溢出,视角与引擎切换可用。
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ npx gitnexus detect-changes --repo "$PWD" --scope staged
|
|||||||
## 结果
|
## 结果
|
||||||
|
|
||||||
- 索引:已完成最终增量更新。
|
- 索引:已完成最终增量更新。
|
||||||
- 暂存文件:20 个文件、67 个符号;GitNexus 自动改写的 `AGENTS.md` 已明确排除。
|
- 暂存文件:15 个文件、66 个符号;GitNexus 自动改写的 `AGENTS.md` 已明确排除。
|
||||||
- 影响执行流:16 条,CLI 风险:critical。
|
- 影响执行流:21 条,CLI 风险:critical。
|
||||||
- 主要执行流:`GetSummary`、`GetIndices`、`GetBreadth`、`GetSectors` 和 `GetFunds`,以及对应映射和状态创建流程。
|
- 主要执行流:`GetSummary`、`GetIndices`、`GetBreadth`、`GetSectors` 和 `GetFunds`,以及对应映射和状态创建流程。
|
||||||
|
|
||||||
## 处置
|
## 处置
|
||||||
|
|||||||
@@ -54,6 +54,8 @@
|
|||||||
|
|
||||||
A 股使用红涨绿跌。成交额统一展示为亿元,比例明确使用百分比。历史数据和低频资金数据必须显示实际数据日期,旧数据不得标记为实时。
|
A 股使用红涨绿跌。成交额统一展示为亿元,比例明确使用百分比。历史数据和低频资金数据必须显示实际数据日期,旧数据不得标记为实时。
|
||||||
|
|
||||||
|
首个切片不引入交易日历依赖:指数、一级行业和成交额的最新交易日期与 `Asia/Shanghai` 当前日历日期不同即标记 `delayed`;市场宽度快照缺少日期字段,使用同批核心指数日线作为日期参考。周末和法定休市日会保守显示延迟并同时展示实际交易日期,后续接入权威交易日历后再细化“最新交易日”语义。
|
||||||
|
|
||||||
## 状态与异常
|
## 状态与异常
|
||||||
|
|
||||||
- 交易中:显示分钟级更新时间。
|
- 交易中:显示分钟级更新时间。
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
## 自动化结果
|
## 自动化结果
|
||||||
|
|
||||||
- 前端 Vitest:7 个文件、14 个测试通过。
|
- 前端 Vitest:8 个文件、20 个测试通过。
|
||||||
- 后端 Vitest/Node:11 个文件、40 个测试通过;Schema、治理、边界和部署门禁通过。
|
- 后端 Vitest:11 个文件、60 个测试通过;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,不阻断门禁。
|
||||||
@@ -11,5 +11,7 @@
|
|||||||
## 手工结果
|
## 手工结果
|
||||||
|
|
||||||
- `GET /api/v1/market-overview/indices` 和 `summary` 返回真实 TDX `fresh` 快照。
|
- `GET /api/v1/market-overview/indices` 和 `summary` 返回真实 TDX `fresh` 快照。
|
||||||
- `breadth`、`sectors`、`funds` 返回明确 `unavailable`,未伪造指标。
|
- TDX 隧道未监听时,五个模块均返回明确不可用状态,未伪造指标;切换到家庭 LAN 隧道后,`summary`、`indices`、`breadth`、`sectors`、`funds` 均返回 `source=tdx,status=fresh`。
|
||||||
|
- 家庭 LAN 真实冒烟中,指数返回 3 项、板块返回 8 项、成交额覆盖 3 个市场且交易日期完整;未保存真实价格或成交额数值。
|
||||||
|
- 修复日期对齐和延迟状态后再次通过家庭 LAN 真实冒烟,五个接口在当日数据下仍为 `fresh` 且无 warning。
|
||||||
- 桌面默认视口与 390px 移动视口无横向溢出;视角和引擎切换可用。
|
- 桌面默认视口与 390px 移动视口无横向溢出;视角和引擎切换可用。
|
||||||
|
|||||||
@@ -11,9 +11,10 @@
|
|||||||
- 通过:桌面 `clientWidth=1440, scrollWidth=1440`,移动端 `clientWidth=390, scrollWidth=390`;移动端六个主要区块左右边界均位于 `14px~376px`,未发生页面级或区块级横向溢出。
|
- 通过:桌面 `clientWidth=1440, scrollWidth=1440`,移动端 `clientWidth=390, scrollWidth=390`;移动端六个主要区块左右边界均位于 `14px~376px`,未发生页面级或区块级横向溢出。
|
||||||
- 通过:综合、短线、中长线切换和规则/AI/混合/对照选择有可见状态。
|
- 通过:综合、短线、中长线切换和规则/AI/混合/对照选择有可见状态。
|
||||||
- 通过:实际切换到“综合”和“混合”后,按钮激活态和下拉选中项同步更新。
|
- 通过:实际切换到“综合”和“混合”后,按钮激活态和下拉选中项同步更新。
|
||||||
- 通过:TDX 来源、更新时间和不可用原因可见;当前 `127.0.0.1:17710` 未监听时,摘要、指数、宽度、板块和成交额趋势均显示结构化不可用状态,没有空白或伪造实时数据。
|
- 通过:家庭 LAN 隧道 `127.0.0.1:17710` 下完成真实 TDX 行情复验;摘要、3 个核心指数、市场宽度、8 个一级行业板块和 3 市成交额均显示 `source=tdx`、`status=fresh`,无模块警告。
|
||||||
- 通过:移动端浏览器控制台无 `warning` 或 `error`。
|
- 通过:桌面与移动端浏览器控制台均无 `warning` 或 `error`;移动端仅有 Vite 开发服务器连接调试信息。
|
||||||
|
- 通过:真实行情移动端 `clientWidth=390, scrollWidth=390`,无页面级横向溢出、内部文字截断或控件重叠;长风险说明、指数数值、板块标签和成交额均完整展示。
|
||||||
|
|
||||||
## 残余项
|
## 残余项
|
||||||
|
|
||||||
依据抽屉、指数/板块详情页和真实图表尚未立项,不作为本次首屏视觉验收项。真实 TDX 数据展示的接口冒烟证据沿用 `tdx-smoke-report.md`;本次隧道未监听,因此没有覆盖或伪造新的真实行情截图。
|
依据抽屉、指数/板块详情页和真实图表尚未立项,不作为本次首屏视觉验收项。真实 TDX 接口值、交易日期和模块状态的逐项证据见 `tdx-smoke-report.md`;本次视觉复验使用同一家庭 LAN 隧道和真实接口响应,不使用离线样本伪装实时行情。
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
- 新增 TDX 直连的市场概览首屏和五个模块 API。
|
- 新增 TDX 直连的市场概览首屏和五个模块 API。
|
||||||
- 支持综合、短线、中长线视角与规则、AI、混合、对照偏好选择。
|
- 支持综合、短线、中长线视角与规则、AI、混合、对照偏好选择。
|
||||||
- 市场宽度、一级行业强弱和成交额趋势接入真实 TDX 数据,并对空数据、部分数据、日期不一致和延迟状态显示明确边界。
|
- 市场宽度、一级行业强弱和成交额趋势接入真实 TDX 数据,并对空数据、部分数据、日期不一致和延迟状态显示明确边界。
|
||||||
|
- 指数和板块按最新交易日截面对齐,成交额按最新有效交易日期对汇总;后端根据上海当前日历日期实际生成 `delayed`,市场宽度使用同批核心指数日期参考。
|
||||||
- 明确显示 TDX 来源、快照时间、交易日期、市场覆盖数和模块级错误。
|
- 明确显示 TDX 来源、快照时间、交易日期、市场覆盖数和模块级错误。
|
||||||
|
|
||||||
## 明确不包含
|
## 明确不包含
|
||||||
|
|||||||
@@ -36,16 +36,22 @@
|
|||||||
|
|
||||||
## GitNexus 与同步修改
|
## GitNexus 与同步修改
|
||||||
|
|
||||||
开发前已保存 GitNexus 影响计划;修改共享运行配置时曾得到 HIGH,原因是配置文件被 API/Scheduler/Worker 共用,已通过移除通用安全摘要变更和定向测试收敛到 API 实际消费者。提交前 CLI reindex 后 `detect-changes --scope staged` 标记 high,具体为 11 条本功能概览/E2E 执行流;已核对核心服务、控制器、前端 API 和测试覆盖,未发现额外业务模块调用方。当前 worktree 的 MCP context 未初始化,已保留 CLI 和静态证据,不把该限制误报为低风险。
|
开发前已保存 GitNexus 影响计划。早期提交曾映射 11 条执行流并标记 high;`49f6be9` 修复提交的 staged 检测映射 20 个文件、67 个符号、16 条执行流并标记 critical。critical 来自市场概览核心服务、TDX 映射和前端数据面板,不是未知跨业务扩散;未发现数据库、Redis、RocketMQ、Scheduler、Worker、认证或部署调用方。当前环境未暴露 GitNexus MCP 变更检测工具,已使用同版本 CLI 和 worktree 绝对路径保留证据。
|
||||||
|
|
||||||
## 性能与资源影响
|
## 性能与资源影响
|
||||||
|
|
||||||
- 五个模块并行请求,单请求超时并限制 1 MiB 响应;不引入 MySQL、Redis、RocketMQ 在线负载。
|
- 五个模块并行请求,单请求超时并限制 1 MiB 响应;市场宽度并行增加一次单指数、单日线日期参考请求,不引入 MySQL、Redis、RocketMQ 在线负载。
|
||||||
- 未进行 7×24、断线重连和生产压测;TDX 宽度/板块/资金方法及详情页仍待独立功能。
|
- 未进行 7×24、断线重连和生产压测;主力净流入、盘中新闻、权威交易日历及详情页仍待独立功能。
|
||||||
- ESLint 对 Vue 单文件组件仅保留 35 条既有风格 warning,无 error;格式、类型和构建门禁均通过。
|
- ESLint 对 Vue 单文件组件仅保留 35 条既有风格 warning,无 error;格式、类型和构建门禁均通过。
|
||||||
|
|
||||||
## 二次 Review
|
## 二次独立 Review
|
||||||
|
|
||||||
等待修复提交完成后执行独立二次 Review。
|
- 审查范围:`ff8ada8..49f6be9`
|
||||||
|
- 结论:`Ready to merge: No`
|
||||||
|
- Critical:无。
|
||||||
|
- Important:宽度仍接受小数/十六进制等非计数值;成交额以首个市场日期为锚;板块跨交易日混排仍标 `fresh`;后端没有真实 `delayed` 路径。
|
||||||
|
- Minor:GitNexus 风险/执行流和 TDX 已接入范围存在旧口径。
|
||||||
|
|
||||||
|
上述问题已按 TDD 增加失败用例后修复:宽度只接受十进制非负安全整数;指数/板块只保留最新交易日;成交额选择最新有效日期对;四个后端日行情路径均具备 `delayed` 判定,宽度使用同批指数日期参考。修复后等待再次独立 Review。
|
||||||
|
|
||||||
最终结论:pending
|
最终结论:pending
|
||||||
|
|||||||
@@ -20,5 +20,7 @@
|
|||||||
- 数据完整性、状态语义、OpenAPI nullable、DTO 输出边界和前端降级提示均已补齐回归测试。
|
- 数据完整性、状态语义、OpenAPI nullable、DTO 输出边界和前端降级提示均已补齐回归测试。
|
||||||
- PRD、发布说明、测试计划和开发总结已同步实际口径,避免实现与文档分叉。
|
- PRD、发布说明、测试计划和开发总结已同步实际口径,避免实现与文档分叉。
|
||||||
- 浏览器复验覆盖桌面与 `390×844` 移动视口;当前 TDX 隧道未监听时只确认结构化降级,历史真实行情冒烟证据保持独立。
|
- 浏览器复验覆盖桌面与 `390×844` 移动视口;当前 TDX 隧道未监听时只确认结构化降级,历史真实行情冒烟证据保持独立。
|
||||||
|
- 二次独立 Review 发现的严格整数、日期对齐和真实 `delayed` 语义已按 TDD 修复;后端新增 9 个断言并完成家庭 LAN 当日行情复验。
|
||||||
|
- 新鲜度暂按上海当前日历日期保守判断,周末和法定休市日可能显示 `delayed`;该残余边界已同步 PRD、设计和测试计划,不伪装成交易日历已接入。
|
||||||
|
|
||||||
最终结论:pending(等待独立二次代码 Review 与完整门禁)
|
最终结论:pending(等待独立二次代码 Review 与完整门禁)
|
||||||
|
|||||||
@@ -17,6 +17,16 @@
|
|||||||
- 端到端耗时:市场宽度约 `0.11s`,一级行业约 `0.27s`,成交额趋势约 `0.12s`(单次本机冒烟,不代表生产 SLA)。
|
- 端到端耗时:市场宽度约 `0.11s`,一级行业约 `0.27s`,成交额趋势约 `0.12s`(单次本机冒烟,不代表生产 SLA)。
|
||||||
- 未调用交易、账户、自选、写入接口;未保存真实行情原始载荷。
|
- 未调用交易、账户、自选、写入接口;未保存真实行情原始载荷。
|
||||||
|
|
||||||
|
## 家庭 LAN 二次复验
|
||||||
|
|
||||||
|
- 验证时间:2026-08-18 19:35 CST。
|
||||||
|
- 先按开发手册切换到家庭 LAN SSH 别名,确认本机 `127.0.0.1:17710` 的监听者为本次 SSH 隧道;平台配置仍只使用回环地址。
|
||||||
|
- 桥接层最小只读 HTTP POST 成功,没有调用交易、账户、自选或写入方法。
|
||||||
|
- Nest API 的 `/summary`、`/indices`、`/breadth`、`/sectors`、`/funds` 均返回 HTTP `200`、`source=tdx`、`status=fresh` 且无 warning。
|
||||||
|
- `/indices` 返回 3 项,`/sectors` 返回 8 项;`/breadth` 返回有限且非负的涨跌家数;`/funds` 返回 `marketCount=3`,当前和上一交易日期均存在且不同。
|
||||||
|
- 本次只记录结构、状态、数量和日期完整性,不记录真实价格、成交额数值、家庭网络地址、SSH 用户或原始响应。
|
||||||
|
- 完成严格整数、最新交易日截面和后端 `delayed` 修复后再次复验:五个接口在当日行情下仍为 `fresh` 且无 warning,证明新增新鲜度判断未误伤当前日期数据。
|
||||||
|
|
||||||
## 脱敏与限制
|
## 脱敏与限制
|
||||||
|
|
||||||
- 报告不保存真实价格、成交量、家庭网络地址、Tailscale 地址、SSH 用户、密钥或完整响应。
|
- 报告不保存真实价格、成交量、家庭网络地址、Tailscale 地址、SSH 用户、密钥或完整响应。
|
||||||
|
|||||||
@@ -13,7 +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 线、成交额缺市场/交易日错位/负数,以及三市完整性状态。
|
- 异常映射:覆盖畸形涨跌家数字段(小数、指数、十六进制和超出安全整数)、空一级行业目录、单根板块 K 线、指数/板块混合交易日、成交额缺市场/交易日错位/负数/首市场落后,以及三市完整性状态。
|
||||||
|
- 后端状态:覆盖指数、市场宽度、一级行业和成交额旧交易日期的真实 `delayed` 分支;休市日保守延迟属于已记录边界。
|
||||||
- 前端状态:覆盖宽度、板块和成交额模块的 `partial`/`delayed` 警告、真实交易日期和成交额零变化中性色。
|
- 前端状态:覆盖宽度、板块和成交额模块的 `partial`/`delayed` 警告、真实交易日期和成交额零变化中性色。
|
||||||
- 浏览器验证:桌面默认视口和 390px 移动视口无横向溢出;综合/短线切换和分析引擎选择可用。
|
- 浏览器验证:桌面默认视口和 390px 移动视口无横向溢出;综合/短线切换和分析引擎选择可用。
|
||||||
|
|
||||||
|
|||||||
@@ -95,13 +95,30 @@ describe('市场概览服务', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('核心指数交易日旧于上海当前日期时标记 delayed', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-08-19T03:00:00.000Z'));
|
||||||
|
try {
|
||||||
|
const client = { request: vi.fn().mockResolvedValue(indexResult) };
|
||||||
|
const service = new MarketOverviewService(client);
|
||||||
|
|
||||||
|
const response = await service.getIndices();
|
||||||
|
|
||||||
|
expect(response.status).toBe('delayed');
|
||||||
|
expect(response.warnings).toEqual(['核心指数交易日期为 20260818,非当前日期']);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('汇总三个市场指数的上涨和下跌家数并保留未知平盘数', async () => {
|
it('汇总三个市场指数的上涨和下跌家数并保留未知平盘数', async () => {
|
||||||
const client = {
|
const client = {
|
||||||
request: vi
|
request: vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValueOnce(breadthSnapshot('10', '5'))
|
.mockResolvedValueOnce(breadthSnapshot('10', '5'))
|
||||||
.mockResolvedValueOnce(breadthSnapshot('20', '15'))
|
.mockResolvedValueOnce(breadthSnapshot('20', '15'))
|
||||||
.mockResolvedValueOnce(breadthSnapshot('3', '2')),
|
.mockResolvedValueOnce(breadthSnapshot('3', '2'))
|
||||||
|
.mockResolvedValueOnce(indexResult),
|
||||||
};
|
};
|
||||||
const service = new MarketOverviewService(client);
|
const service = new MarketOverviewService(client);
|
||||||
|
|
||||||
@@ -124,7 +141,8 @@ describe('市场概览服务', () => {
|
|||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValueOnce(breadthSnapshot('10', '5'))
|
.mockResolvedValueOnce(breadthSnapshot('10', '5'))
|
||||||
.mockResolvedValueOnce({ ErrorId: '0', UpHome: '', DownHome: '3' })
|
.mockResolvedValueOnce({ ErrorId: '0', UpHome: '', DownHome: '3' })
|
||||||
.mockRejectedValueOnce(new Error('raw network detail')),
|
.mockRejectedValueOnce(new Error('raw network detail'))
|
||||||
|
.mockResolvedValueOnce(indexResult),
|
||||||
};
|
};
|
||||||
const service = new MarketOverviewService(client);
|
const service = new MarketOverviewService(client);
|
||||||
|
|
||||||
@@ -138,6 +156,31 @@ describe('市场概览服务', () => {
|
|||||||
expect(response.warnings.join(' ')).not.toContain('raw network detail');
|
expect(response.warnings.join(' ')).not.toContain('raw network detail');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('市场宽度使用核心指数交易日识别 delayed,不把旧快照标为 fresh', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-08-18T03:00:00.000Z'));
|
||||||
|
try {
|
||||||
|
const staleIndexResult = structuredClone(indexResult);
|
||||||
|
staleIndexResult.Value['000001.SH'].Date = ['20260816', '20260817'];
|
||||||
|
const client = {
|
||||||
|
request: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(breadthSnapshot('10', '5'))
|
||||||
|
.mockResolvedValueOnce(breadthSnapshot('20', '15'))
|
||||||
|
.mockResolvedValueOnce(breadthSnapshot('3', '2'))
|
||||||
|
.mockResolvedValueOnce(staleIndexResult),
|
||||||
|
};
|
||||||
|
const service = new MarketOverviewService(client);
|
||||||
|
|
||||||
|
const response = await service.getBreadth();
|
||||||
|
|
||||||
|
expect(response.status).toBe('delayed');
|
||||||
|
expect(response.warnings).toEqual(['市场宽度交易日期为 20260817,非当前日期']);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('按一级行业涨跌幅排序板块并保留 TDX 代码', async () => {
|
it('按一级行业涨跌幅排序板块并保留 TDX 代码', async () => {
|
||||||
const client = {
|
const client = {
|
||||||
request: vi.fn().mockResolvedValueOnce(sectorResult).mockResolvedValueOnce(sectorKlineResult),
|
request: vi.fn().mockResolvedValueOnce(sectorResult).mockResolvedValueOnce(sectorKlineResult),
|
||||||
@@ -202,6 +245,44 @@ describe('市场概览服务', () => {
|
|||||||
expect(response.data?.items[0].name).toBe('行业8');
|
expect(response.data?.items[0].name).toBe('行业8');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('一级行业混合交易日时返回 partial,不把不同日期误标 fresh', async () => {
|
||||||
|
const mixedDates = structuredClone(sectorKlineResult);
|
||||||
|
mixedDates.Value['881355.SH'].Date = ['20260818', '20260819'];
|
||||||
|
const client = {
|
||||||
|
request: vi.fn().mockResolvedValueOnce(sectorResult).mockResolvedValueOnce(mixedDates),
|
||||||
|
};
|
||||||
|
const service = new MarketOverviewService(client);
|
||||||
|
|
||||||
|
const response = await service.getSectors();
|
||||||
|
|
||||||
|
expect(response).toMatchObject({
|
||||||
|
status: 'partial',
|
||||||
|
data: { items: [expect.objectContaining({ code: '881355.SH', tradeDate: '20260819' })] },
|
||||||
|
});
|
||||||
|
expect(response.warnings).toEqual(['部分一级行业未返回有效行情']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('一级行业交易日旧于上海当前日期时标记 delayed', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-08-19T03:00:00.000Z'));
|
||||||
|
try {
|
||||||
|
const client = {
|
||||||
|
request: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(sectorResult)
|
||||||
|
.mockResolvedValueOnce(sectorKlineResult),
|
||||||
|
};
|
||||||
|
const service = new MarketOverviewService(client);
|
||||||
|
|
||||||
|
const response = await service.getSectors();
|
||||||
|
|
||||||
|
expect(response.status).toBe('delayed');
|
||||||
|
expect(response.warnings).toEqual(['一级行业交易日期为 20260818,非当前日期']);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
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);
|
||||||
@@ -238,4 +319,20 @@ describe('市场概览服务', () => {
|
|||||||
});
|
});
|
||||||
expect(response.warnings).toEqual(['部分市场成交额不可用,当前汇总 2 个市场']);
|
expect(response.warnings).toEqual(['部分市场成交额不可用,当前汇总 2 个市场']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('成交额交易日旧于上海当前日期时标记 delayed', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-08-19T03:00:00.000Z'));
|
||||||
|
try {
|
||||||
|
const client = { request: vi.fn().mockResolvedValue(turnoverResult) };
|
||||||
|
const service = new MarketOverviewService(client);
|
||||||
|
|
||||||
|
const response = await service.getFunds();
|
||||||
|
|
||||||
|
expect(response.status).toBe('delayed');
|
||||||
|
expect(response.warnings).toEqual(['成交额交易日期为 20260818,非当前日期']);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,6 +42,25 @@ function createSnapshotMeta(): Pick<
|
|||||||
return { snapshotId, asOf, source: 'tdx' };
|
return { snapshotId, asOf, source: 'tdx' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 交易日状态统一按上海日历判断;休市日保守标记 delayed,避免把历史值冒充实时。 */
|
||||||
|
function getShanghaiCalendarDate(asOf: string): string {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: 'Asia/Shanghai',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
}).formatToParts(new Date(asOf));
|
||||||
|
const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
|
||||||
|
return `${values.year}${values.month}${values.day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTradeDateWarning(subject: string, tradeDate: string | undefined, asOf: string) {
|
||||||
|
if (!tradeDate) return `${subject}交易日期无法确认`;
|
||||||
|
return tradeDate === getShanghaiCalendarDate(asOf)
|
||||||
|
? null
|
||||||
|
: `${subject}交易日期为 ${tradeDate},非当前日期`;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MarketOverviewService {
|
export class MarketOverviewService {
|
||||||
constructor(@Inject(TDX_CLIENT_PORT) private readonly tdxClient: TdxClient) {}
|
constructor(@Inject(TDX_CLIENT_PORT) private readonly tdxClient: TdxClient) {}
|
||||||
@@ -56,12 +75,19 @@ export class MarketOverviewService {
|
|||||||
period: '1d',
|
period: '1d',
|
||||||
});
|
});
|
||||||
const items = mapIndexQuotes(result, CORE_INDEXES);
|
const items = mapIndexQuotes(result, CORE_INDEXES);
|
||||||
const status = items.length === CORE_INDEXES.length ? 'fresh' : 'partial';
|
const tradeDateWarning = createTradeDateWarning('核心指数', items[0]?.tradeDate, meta.asOf);
|
||||||
|
const status =
|
||||||
|
items.length !== CORE_INDEXES.length ? 'partial' : tradeDateWarning ? 'delayed' : 'fresh';
|
||||||
return {
|
return {
|
||||||
...meta,
|
...meta,
|
||||||
status,
|
status,
|
||||||
data: { items },
|
data: { items },
|
||||||
warnings: status === 'partial' ? ['部分核心指数未返回有效数据'] : [],
|
warnings:
|
||||||
|
status === 'partial'
|
||||||
|
? ['部分核心指数未返回有效数据']
|
||||||
|
: tradeDateWarning
|
||||||
|
? [tradeDateWarning]
|
||||||
|
: [],
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
@@ -105,13 +131,23 @@ export class MarketOverviewService {
|
|||||||
|
|
||||||
async getBreadth(): Promise<MarketOverviewResponse<BreadthDataDto>> {
|
async getBreadth(): Promise<MarketOverviewResponse<BreadthDataDto>> {
|
||||||
const meta = createSnapshotMeta();
|
const meta = createSnapshotMeta();
|
||||||
const results = await Promise.allSettled(
|
const [results, [referenceResult]] = await Promise.all([
|
||||||
|
Promise.allSettled(
|
||||||
BREADTH_INDEXES.map((item) =>
|
BREADTH_INDEXES.map((item) =>
|
||||||
this.tdxClient.request<TdxMarketSnapshotResult>('get_market_snapshot', {
|
this.tdxClient.request<TdxMarketSnapshotResult>('get_market_snapshot', {
|
||||||
stock_code: item.code,
|
stock_code: item.code,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
|
Promise.allSettled([
|
||||||
|
this.tdxClient.request<TdxMarketDataResult>('get_market_data', {
|
||||||
|
stock_list: [CORE_INDEXES[0].code],
|
||||||
|
count: 1,
|
||||||
|
dividend_type: 'none',
|
||||||
|
period: '1d',
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
const validSnapshots = results.flatMap((result) => {
|
const validSnapshots = results.flatMap((result) => {
|
||||||
if (result.status === 'rejected') return [];
|
if (result.status === 'rejected') return [];
|
||||||
return mapBreadthSnapshots([result.value]).validCount === 1 ? [result.value] : [];
|
return mapBreadthSnapshots([result.value]).validCount === 1 ? [result.value] : [];
|
||||||
@@ -133,13 +169,27 @@ export class MarketOverviewService {
|
|||||||
warnings: [`市场宽度数据不可用:${unavailableMarkets.join('、')}`],
|
warnings: [`市场宽度数据不可用:${unavailableMarkets.join('、')}`],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const status = mapped.validCount === BREADTH_INDEXES.length ? 'fresh' : 'partial';
|
const referenceTradeDate =
|
||||||
|
referenceResult?.status === 'fulfilled'
|
||||||
|
? mapIndexQuotes(referenceResult.value, [CORE_INDEXES[0]])[0]?.tradeDate
|
||||||
|
: undefined;
|
||||||
|
const tradeDateWarning = createTradeDateWarning('市场宽度', referenceTradeDate, meta.asOf);
|
||||||
|
const status =
|
||||||
|
mapped.validCount !== BREADTH_INDEXES.length
|
||||||
|
? 'partial'
|
||||||
|
: tradeDateWarning
|
||||||
|
? 'delayed'
|
||||||
|
: 'fresh';
|
||||||
return {
|
return {
|
||||||
...meta,
|
...meta,
|
||||||
status,
|
status,
|
||||||
data: mapped.data,
|
data: mapped.data,
|
||||||
warnings:
|
warnings:
|
||||||
status === 'partial' ? [`部分市场涨跌家数不可用:${unavailableMarkets.join('、')}`] : [],
|
status === 'partial'
|
||||||
|
? [`部分市场涨跌家数不可用:${unavailableMarkets.join('、')}`]
|
||||||
|
: tradeDateWarning
|
||||||
|
? [tradeDateWarning]
|
||||||
|
: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,12 +217,19 @@ export class MarketOverviewService {
|
|||||||
});
|
});
|
||||||
const mappedItems = mapSectorQuotes(result, definitions);
|
const mappedItems = mapSectorQuotes(result, definitions);
|
||||||
const items = mappedItems.slice(0, 8);
|
const items = mappedItems.slice(0, 8);
|
||||||
|
const tradeDateWarning = createTradeDateWarning(
|
||||||
|
'一级行业',
|
||||||
|
mappedItems[0]?.tradeDate,
|
||||||
|
meta.asOf,
|
||||||
|
);
|
||||||
const status =
|
const status =
|
||||||
mappedItems.length === definitions.length
|
mappedItems.length === 0
|
||||||
? 'fresh'
|
? 'unavailable'
|
||||||
: mappedItems.length
|
: mappedItems.length !== definitions.length
|
||||||
? 'partial'
|
? 'partial'
|
||||||
: 'unavailable';
|
: tradeDateWarning
|
||||||
|
? 'delayed'
|
||||||
|
: 'fresh';
|
||||||
return {
|
return {
|
||||||
...meta,
|
...meta,
|
||||||
status,
|
status,
|
||||||
@@ -180,7 +237,11 @@ export class MarketOverviewService {
|
|||||||
warnings:
|
warnings:
|
||||||
status === 'fresh'
|
status === 'fresh'
|
||||||
? []
|
? []
|
||||||
: [status === 'partial' ? '部分一级行业未返回有效行情' : '一级行业行情不可用'],
|
: status === 'partial'
|
||||||
|
? ['部分一级行业未返回有效行情']
|
||||||
|
: status === 'delayed' && tradeDateWarning
|
||||||
|
? [tradeDateWarning]
|
||||||
|
: ['一级行业行情不可用'],
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
@@ -204,7 +265,13 @@ export class MarketOverviewService {
|
|||||||
const mapped = mapTurnoverTrend(result, TURNOVER_INDEXES);
|
const mapped = mapTurnoverTrend(result, TURNOVER_INDEXES);
|
||||||
if (!mapped.data)
|
if (!mapped.data)
|
||||||
return { ...meta, status: 'unavailable', data: null, warnings: ['成交额趋势数据不完整'] };
|
return { ...meta, status: 'unavailable', data: null, warnings: ['成交额趋势数据不完整'] };
|
||||||
const status = mapped.validMarketCount === TURNOVER_INDEXES.length ? 'fresh' : 'partial';
|
const tradeDateWarning = createTradeDateWarning('成交额', mapped.data.tradeDate, meta.asOf);
|
||||||
|
const status =
|
||||||
|
mapped.validMarketCount !== TURNOVER_INDEXES.length
|
||||||
|
? 'partial'
|
||||||
|
: tradeDateWarning
|
||||||
|
? 'delayed'
|
||||||
|
: 'fresh';
|
||||||
return {
|
return {
|
||||||
...meta,
|
...meta,
|
||||||
status,
|
status,
|
||||||
@@ -212,6 +279,8 @@ export class MarketOverviewService {
|
|||||||
warnings:
|
warnings:
|
||||||
status === 'partial'
|
status === 'partial'
|
||||||
? [`部分市场成交额不可用,当前汇总 ${mapped.validMarketCount} 个市场`]
|
? [`部分市场成交额不可用,当前汇总 ${mapped.validMarketCount} 个市场`]
|
||||||
|
: tradeDateWarning
|
||||||
|
? [tradeDateWarning]
|
||||||
: [],
|
: [],
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -87,6 +87,55 @@ describe('TDX 行情映射', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('市场宽度拒绝小数和十六进制字符串,避免把非计数值纳入汇总', () => {
|
||||||
|
expect(
|
||||||
|
mapBreadthSnapshots([
|
||||||
|
{ UpHome: '1.5', DownHome: '5' },
|
||||||
|
{ UpHome: '0x10', DownHome: '2' },
|
||||||
|
{ UpHome: '1e3', DownHome: '2' },
|
||||||
|
{ UpHome: '9007199254740992', DownHome: '2' },
|
||||||
|
{ UpHome: '10', DownHome: '5' },
|
||||||
|
]),
|
||||||
|
).toEqual({
|
||||||
|
data: { advancing: 10, declining: 5, unchanged: null },
|
||||||
|
validCount: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('核心指数混合交易日时只保留最新交易日,避免跨日组合', () => {
|
||||||
|
const result = mapIndexQuotes(
|
||||||
|
{
|
||||||
|
ErrorId: '0',
|
||||||
|
Value: {
|
||||||
|
'000001.SH': {
|
||||||
|
Date: ['20260817', '20260818'],
|
||||||
|
Open: ['1', '1'],
|
||||||
|
High: ['1', '1'],
|
||||||
|
Low: ['1', '1'],
|
||||||
|
Close: ['100', '102'],
|
||||||
|
Amount: ['1', '1'],
|
||||||
|
Volume: ['1', '1'],
|
||||||
|
},
|
||||||
|
'399001.SZ': {
|
||||||
|
Date: ['20260818', '20260819'],
|
||||||
|
Open: ['1', '1'],
|
||||||
|
High: ['1', '1'],
|
||||||
|
Low: ['1', '1'],
|
||||||
|
Close: ['100', '101'],
|
||||||
|
Amount: ['1', '1'],
|
||||||
|
Volume: ['1', '1'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[
|
||||||
|
{ code: '000001.SH', name: '旧日期指数' },
|
||||||
|
{ code: '399001.SZ', name: '最新日期指数' },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual([expect.objectContaining({ code: '399001.SZ', tradeDate: '20260819' })]);
|
||||||
|
});
|
||||||
|
|
||||||
it('板块只保留有两个交易日和有效涨跌幅的精确字段', () => {
|
it('板块只保留有两个交易日和有效涨跌幅的精确字段', () => {
|
||||||
const result = mapSectorQuotes(
|
const result = mapSectorQuotes(
|
||||||
{
|
{
|
||||||
@@ -129,6 +178,33 @@ describe('TDX 行情映射', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('板块混合交易日时只保留最新交易日,避免把不同日期混排', () => {
|
||||||
|
const series = (dates: string[], close: string[]) => ({
|
||||||
|
Date: dates,
|
||||||
|
Open: close,
|
||||||
|
High: close,
|
||||||
|
Low: close,
|
||||||
|
Close: close,
|
||||||
|
Amount: ['1', '1'],
|
||||||
|
Volume: ['1', '1'],
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
mapSectorQuotes(
|
||||||
|
{
|
||||||
|
ErrorId: '0',
|
||||||
|
Value: {
|
||||||
|
'881001.SH': series(['20260817', '20260818'], ['100', '102']),
|
||||||
|
'881002.SH': series(['20260818', '20260819'], ['100', '101']),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[
|
||||||
|
{ code: '881001.SH', name: '旧日期行业' },
|
||||||
|
{ code: '881002.SH', name: '最新日期行业' },
|
||||||
|
],
|
||||||
|
),
|
||||||
|
).toEqual([expect.objectContaining({ code: '881002.SH', tradeDate: '20260819' })]);
|
||||||
|
});
|
||||||
|
|
||||||
it('成交额映射将 TDX 万元转换为亿元并计算环比', () => {
|
it('成交额映射将 TDX 万元转换为亿元并计算环比', () => {
|
||||||
expect(
|
expect(
|
||||||
mapTurnoverTrend(
|
mapTurnoverTrend(
|
||||||
@@ -193,4 +269,44 @@ describe('TDX 行情映射', () => {
|
|||||||
data: { currentAmount: 12, previousAmount: 10, marketCount: 1, tradeDate: '20260818' },
|
data: { currentAmount: 12, previousAmount: 10, marketCount: 1, tradeDate: '20260818' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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(['20260818', '20260819'], ['200000', '240000']),
|
||||||
|
'899050.BJ': series(['20260818', '20260819'], ['300000', '360000']),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[
|
||||||
|
{ code: '000001.SH', name: '上证市场' },
|
||||||
|
{ code: '399001.SZ', name: '深证市场' },
|
||||||
|
{ code: '899050.BJ', name: '北证市场' },
|
||||||
|
],
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
data: {
|
||||||
|
currentAmount: 60,
|
||||||
|
previousAmount: 50,
|
||||||
|
changePercent: 20,
|
||||||
|
unit: '亿元',
|
||||||
|
marketCount: 2,
|
||||||
|
tradeDate: '20260819',
|
||||||
|
previousTradeDate: '20260818',
|
||||||
|
},
|
||||||
|
validMarketCount: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,6 +17,24 @@ function toNonNegativeNumber(value: string | undefined): number | null {
|
|||||||
return parsed !== null && parsed >= 0 ? parsed : null;
|
return parsed !== null && parsed >= 0 ? parsed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toNonNegativeInteger(value: string | undefined): number | null {
|
||||||
|
const normalized = value?.trim();
|
||||||
|
if (!normalized || !/^\d+$/.test(normalized)) return null;
|
||||||
|
const parsed = Number(normalized);
|
||||||
|
return Number.isSafeInteger(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTradeDate(value: string | undefined): string | null {
|
||||||
|
return value && /^\d{8}$/.test(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function latestTradeDate<T extends { tradeDate: string }>(items: readonly T[]): string | null {
|
||||||
|
return items.reduce<string | null>(
|
||||||
|
(latest, item) => (latest === null || item.tradeDate > latest ? item.tradeDate : latest),
|
||||||
|
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';
|
||||||
@@ -26,7 +44,7 @@ function mapIndexQuote(series: TdxKlineSeries, definition: IndexDefinition): Ind
|
|||||||
const lastIndex = series.Close.length - 1;
|
const lastIndex = series.Close.length - 1;
|
||||||
const price = toNumber(series.Close[lastIndex]);
|
const price = toNumber(series.Close[lastIndex]);
|
||||||
const previousPrice = toNumber(series.Close[lastIndex - 1]);
|
const previousPrice = toNumber(series.Close[lastIndex - 1]);
|
||||||
const tradeDate = series.Date[lastIndex];
|
const tradeDate = toTradeDate(series.Date[lastIndex]);
|
||||||
if (price === null || !tradeDate) return null;
|
if (price === null || !tradeDate) return null;
|
||||||
|
|
||||||
const changePercent =
|
const changePercent =
|
||||||
@@ -49,12 +67,14 @@ export function mapIndexQuotes(
|
|||||||
result: TdxMarketDataResult,
|
result: TdxMarketDataResult,
|
||||||
definitions: readonly IndexDefinition[],
|
definitions: readonly IndexDefinition[],
|
||||||
): IndexQuoteDto[] {
|
): IndexQuoteDto[] {
|
||||||
return 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 [];
|
||||||
const quote = mapIndexQuote(series, definition);
|
const quote = mapIndexQuote(series, definition);
|
||||||
return quote ? [quote] : [];
|
return quote ? [quote] : [];
|
||||||
});
|
});
|
||||||
|
const tradeDate = latestTradeDate(candidates);
|
||||||
|
return tradeDate ? candidates.filter((item) => item.tradeDate === tradeDate) : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** TDX 涨跌家数必须成对有效且非负;无效市场整体排除,禁止把缺失字段折算为零。 */
|
/** TDX 涨跌家数必须成对有效且非负;无效市场整体排除,禁止把缺失字段折算为零。 */
|
||||||
@@ -63,8 +83,8 @@ export function mapBreadthSnapshots(snapshots: readonly { UpHome?: string; DownH
|
|||||||
validCount: number;
|
validCount: number;
|
||||||
} {
|
} {
|
||||||
const validSnapshots = snapshots.flatMap((item) => {
|
const validSnapshots = snapshots.flatMap((item) => {
|
||||||
const advancing = toNonNegativeNumber(item.UpHome);
|
const advancing = toNonNegativeInteger(item.UpHome);
|
||||||
const declining = toNonNegativeNumber(item.DownHome);
|
const declining = toNonNegativeInteger(item.DownHome);
|
||||||
return advancing === null || declining === null ? [] : [{ advancing, declining }];
|
return advancing === null || declining === null ? [] : [{ advancing, declining }];
|
||||||
});
|
});
|
||||||
if (!validSnapshots.length) return { data: null, validCount: 0 };
|
if (!validSnapshots.length) return { data: null, validCount: 0 };
|
||||||
@@ -84,8 +104,7 @@ export function mapSectorQuotes(
|
|||||||
result: TdxMarketDataResult,
|
result: TdxMarketDataResult,
|
||||||
definitions: readonly IndexDefinition[],
|
definitions: readonly IndexDefinition[],
|
||||||
): SectorQuoteDto[] {
|
): SectorQuoteDto[] {
|
||||||
return definitions
|
const candidates = definitions.flatMap((definition) => {
|
||||||
.flatMap((definition) => {
|
|
||||||
const series = result.Value[definition.code];
|
const series = result.Value[definition.code];
|
||||||
if (!series) return [];
|
if (!series) return [];
|
||||||
const quote = mapIndexQuote(series, definition);
|
const quote = mapIndexQuote(series, definition);
|
||||||
@@ -99,7 +118,10 @@ export function mapSectorQuotes(
|
|||||||
tradeDate: quote.tradeDate,
|
tradeDate: quote.tradeDate,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
})
|
});
|
||||||
|
const tradeDate = latestTradeDate(candidates);
|
||||||
|
return candidates
|
||||||
|
.filter((item) => item.tradeDate === tradeDate)
|
||||||
.sort((left, right) => right.changePercent - left.changePercent);
|
.sort((left, right) => right.changePercent - left.changePercent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,8 +135,8 @@ export function mapTurnoverTrend(
|
|||||||
if (!series) return [];
|
if (!series) 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 = series.Date.at(-1);
|
const tradeDate = toTradeDate(series.Date.at(-1));
|
||||||
const previousTradeDate = series.Date.at(-2);
|
const previousTradeDate = toTradeDate(series.Date.at(-2));
|
||||||
if (
|
if (
|
||||||
current === null ||
|
current === null ||
|
||||||
previous === null ||
|
previous === null ||
|
||||||
@@ -129,7 +151,12 @@ export function mapTurnoverTrend(
|
|||||||
});
|
});
|
||||||
if (!candidates.length) return { data: null, validMarketCount: 0 };
|
if (!candidates.length) return { data: null, validMarketCount: 0 };
|
||||||
|
|
||||||
const expected = candidates[0];
|
const expected = candidates.reduce((latest, item) =>
|
||||||
|
item.tradeDate > latest.tradeDate ||
|
||||||
|
(item.tradeDate === latest.tradeDate && item.previousTradeDate > latest.previousTradeDate)
|
||||||
|
? item
|
||||||
|
: latest,
|
||||||
|
);
|
||||||
const aligned = candidates.filter(
|
const aligned = candidates.filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.tradeDate === expected.tradeDate &&
|
item.tradeDate === expected.tradeDate &&
|
||||||
|
|||||||
Reference in New Issue
Block a user