feat(FEAT-MO-001): 接入TDX市场宽度板块与成交额趋势
This commit is contained in:
@@ -5,8 +5,11 @@
|
|||||||
## 数据可用性
|
## 数据可用性
|
||||||
|
|
||||||
- `summary` 和 `indices`:使用 TDX K 线真实响应,展示来源、快照时间和指数涨跌。
|
- `summary` 和 `indices`:使用 TDX K 线真实响应,展示来源、快照时间和指数涨跌。
|
||||||
- `breadth`、`sectors`、`funds`:桥接层暂未注册对应方法,使用 `unavailable` 响应和明确原因。
|
- `breadth`:使用 TDX 指数快照的 `UpHome`、`DownHome` 汇总上证、深证和北证市场宽度;未返回的 `unchanged` 明确为 `null`,不得用总数推算。
|
||||||
- 页面不把不可用指标降级为模拟值;后续补齐桥接方法时只替换后端映射,不改变前端契约。
|
- `sectors`:使用 TDX 系统分类中的一级行业(`get_stock_list(market=16)`)和批量日行情,按涨跌幅排序后展示重点强弱板块。
|
||||||
|
- `funds`:模块名称和口径为“成交额趋势”,使用核心交易所指数日行情的 `Amount` 汇总当前与前一交易日成交额;这不是主力净流入。
|
||||||
|
- 主力净流入和盘中新闻不在本次 TDX 首屏主链路中,后续通过独立补充数据源适配器接入,并在响应中标记来源与数据质量。
|
||||||
|
- 页面不把缺失或权限不足的指标降级为模拟值;单个来源失败只影响对应模块。
|
||||||
|
|
||||||
## 运行边界
|
## 运行边界
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
- 数据源从早期“确定性模拟快照”调整为 TDX 只读直连,Nest API 访问本机 `127.0.0.1:17710`,Vue 不直接访问 TDX。
|
- 数据源从早期“确定性模拟快照”调整为 TDX 只读直连,Nest API 访问本机 `127.0.0.1:17710`,Vue 不直接访问 TDX。
|
||||||
- API 按摘要、指数、市场宽度、板块和资金拆分,避免把整个首屏塞入单个接口。
|
- API 按摘要、指数、市场宽度、板块和资金拆分,避免把整个首屏塞入单个接口。
|
||||||
- 当前 TDX 桥接层仅确认 `get_market_data` 可用;宽度、板块和资金方法未注册,因此对应模块明确返回 `unavailable`。
|
- 当前 TDX 桥接已确认 `get_market_snapshot`、`get_stock_list` 和批量 `get_market_data` 可用;市场宽度、一级行业板块和成交额趋势改为真实 TDX 映射。主力净流入字段虽存在但当前实测为零值,盘中新闻无官方行情接口,因此两者延期到补充源功能。
|
||||||
- MySQL、Redis、RocketMQ 和 AI 仍不进入本切片运行链路;它们保留为后续基础设施边界。
|
- MySQL、Redis、RocketMQ 和 AI 仍不进入本切片运行链路;它们保留为后续基础设施边界。
|
||||||
|
|
||||||
## 实际完成需求
|
## 实际完成需求
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
- 完成结论、驱动与风险、核心指数、市场宽度、板块、资金趋势模块,以及加载/错误/不可用状态。
|
- 完成结论、驱动与风险、核心指数、市场宽度、板块、资金趋势模块,以及加载/错误/不可用状态。
|
||||||
- 完成分析视角和分析引擎切换,本地保存用户偏好。
|
- 完成分析视角和分析引擎切换,本地保存用户偏好。
|
||||||
- 真实 TDX 指数和摘要已通过本机隧道冒烟;未用指数或离线样本伪造缺失指标。
|
- 真实 TDX 指数和摘要已通过本机隧道冒烟;未用指数或离线样本伪造缺失指标。
|
||||||
|
- 真实 TDX 市场宽度、一级行业和成交额趋势已通过本机桥接冒烟;资金模块明确使用成交额口径,不宣称主力净流入。
|
||||||
|
|
||||||
## 修复缺陷
|
## 修复缺陷
|
||||||
|
|
||||||
@@ -29,7 +30,7 @@
|
|||||||
|
|
||||||
## 未完成与延期项
|
## 未完成与延期项
|
||||||
|
|
||||||
- TDX 桥接层补齐市场宽度、板块排行、资金趋势方法后再实现真实图表和口径。
|
- 主力净流入的权限/数据质量验证、盘中新闻补充源、真实图表和口径仍延期。
|
||||||
- “查看依据”抽屉、指数/板块详情页、AI 请求和用户体系另立功能。
|
- “查看依据”抽屉、指数/板块详情页、AI 请求和用户体系另立功能。
|
||||||
- 本地验证不代表生产 7×24 采集稳定性、商业授权或公网部署安全性。
|
- 本地验证不代表生产 7×24 采集稳定性、商业授权或公网部署安全性。
|
||||||
|
|
||||||
@@ -41,7 +42,7 @@
|
|||||||
## 验证证据
|
## 验证证据
|
||||||
|
|
||||||
- GitNexus 已完成最终重新索引;提交前 CLI `detect-changes --scope staged` 映射 11 条概览/E2E 执行流并标记 high。高风险来自本功能修改核心概览服务和测试启动入口,已由定向单测、HTTP/OpenAPI、构建和 E2E 覆盖;未发现额外业务模块调用方。MCP context 在当前 worktree 未初始化,因此以 CLI 索引和静态调用证据补充记录。
|
- GitNexus 已完成最终重新索引;提交前 CLI `detect-changes --scope staged` 映射 11 条概览/E2E 执行流并标记 high。高风险来自本功能修改核心概览服务和测试启动入口,已由定向单测、HTTP/OpenAPI、构建和 E2E 覆盖;未发现额外业务模块调用方。MCP context 在当前 worktree 未初始化,因此以 CLI 索引和静态调用证据补充记录。
|
||||||
- API 冒烟:`/summary`、`/indices` 返回 `source=tdx,status=fresh`。
|
- 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 移动视口验证无横向溢出,视角与引擎切换可用。
|
||||||
|
|||||||
@@ -6,4 +6,6 @@
|
|||||||
4. ~~实现指数、宽度、板块和资金模块;不可用指标使用结构化状态。~~ 已完成。
|
4. ~~实现指数、宽度、板块和资金模块;不可用指标使用结构化状态。~~ 已完成。
|
||||||
5. ~~实现分析偏好菜单和本地持久化。~~ 已完成;依据抽屉和详情页入口延期至独立功能。
|
5. ~~实现分析偏好菜单和本地持久化。~~ 已完成;依据抽屉和详情页入口延期至独立功能。
|
||||||
6. ~~完成单元测试、类型检查、构建、接口冒烟、浏览器桌面/移动端视觉验证。~~ 已完成。
|
6. ~~完成单元测试、类型检查、构建、接口冒烟、浏览器桌面/移动端视觉验证。~~ 已完成。
|
||||||
7. 补齐 TDX 桥接层的宽度、板块和资金方法后,再单独更新对应数据口径和图表需求。
|
7. ~~补齐 TDX 市场宽度、一级行业板块和成交额趋势映射。~~ 已确认官方接口和本机桥接可用;主力净流入与盘中新闻保留为补充源需求。
|
||||||
|
8. 更新 DTO、OpenAPI 类型和前端组件,使真实数据状态、单位和 `unchanged=null` 口径可见。
|
||||||
|
9. 完成 TDX 真实响应夹具、错误隔离、类型、构建和浏览器视觉验证;Review 后再收口功能档案。
|
||||||
|
|||||||
@@ -93,3 +93,4 @@ A 股使用红涨绿跌。成交额统一展示为亿元,比例明确使用百
|
|||||||
|
|
||||||
- 0.1:建立首版 PRD,补充结论优先、分析偏好和异常状态。
|
- 0.1:建立首版 PRD,补充结论优先、分析偏好和异常状态。
|
||||||
- 0.2:根据 TDX 实连验证修正数据源、可用性边界和未接入基础设施范围。
|
- 0.2:根据 TDX 实连验证修正数据源、可用性边界和未接入基础设施范围。
|
||||||
|
- 0.3:核对官方行情接口后确认市场宽度、一级行业板块和成交额趋势可由 TDX 直接提供;主力净流入与盘中新闻保留补充源边界。
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
首屏为“市场概览”模块中的综合概览,采用结论优先。市场概览内部通过轻量分析状态入口切换综合、短线和中长线视角,并支持规则、AI、混合与对照分析。
|
首屏为“市场概览”模块中的综合概览,采用结论优先。市场概览内部通过轻量分析状态入口切换综合、短线和中长线视角,并支持规则、AI、混合与对照分析。
|
||||||
|
|
||||||
当前切片开发 Vue 3 可交互首屏,Nest API 通过 TDX 只读桥接层提供真实指数和摘要;市场宽度、板块和资金接口保留结构化不可用状态,不使用离线样本。不接用户体系,也不启用 MySQL、Redis、RocketMQ 或 AI 运行链路。
|
当前切片开发 Vue 3 可交互首屏,Nest API 通过 TDX 只读桥接层提供真实指数、市场宽度、板块和成交额趋势;主力净流入与盘中新闻不作为本次 TDX 主链路指标,不使用离线样本。不接用户体系,也不启用 MySQL、Redis、RocketMQ 或 AI 运行链路。
|
||||||
|
|
||||||
## 本次实际范围
|
## 本次实际范围
|
||||||
|
|
||||||
- 已完成综合、短线、中长线视角切换,以及规则、AI、混合、对照偏好选择;偏好只保存于浏览器本地。
|
- 已完成综合、短线、中长线视角切换,以及规则、AI、混合、对照偏好选择;偏好只保存于浏览器本地。
|
||||||
- 已完成按摘要、指数、宽度、板块、资金拆分的五个 HTTP 接口和前端 API 封装。
|
- 已完成按摘要、指数、宽度、板块、资金拆分的五个 HTTP 接口和前端 API 封装。
|
||||||
- 已完成 TDX 请求超时、响应大小限制、错误脱敏和模块级失败隔离。
|
- 已完成 TDX 请求超时、响应大小限制、错误脱敏和模块级失败隔离。
|
||||||
|
- 已确认并接入 TDX 市场宽度、一级行业板块和成交额趋势;资金模块明确不等同于主力净流入,`unchanged` 无可靠总数时返回 `null`。
|
||||||
- 详情页和“查看依据”抽屉只保留入口,另立需求实现,不在本次发布范围内。
|
- 详情页和“查看依据”抽屉只保留入口,另立需求实现,不在本次发布范围内。
|
||||||
|
|||||||
@@ -1,22 +1,24 @@
|
|||||||
# FEAT-MO-001 TDX 只读冒烟报告
|
# FEAT-MO-001 TDX 只读冒烟报告
|
||||||
|
|
||||||
- 验证时间:2026-08-18 13:04 CST
|
- 验证时间:2026-08-18 17:21 CST
|
||||||
- 网络场景:外出 Tailscale + SSH 隧道
|
- 网络场景:本机回环桥接(TDX 服务可达)
|
||||||
- 本机业务入口:`127.0.0.1:17710`
|
- 本机业务入口:`127.0.0.1:17710`
|
||||||
- 请求方式:HTTP POST JSON
|
- API 入口:`127.0.0.1:3000/api/v1/market-overview`
|
||||||
- 请求类型:单只公开行情 K 线只读请求
|
- 请求方式:HTTP GET(业务 API)及桥接层 HTTP POST JSON
|
||||||
|
- 请求类型:市场宽度、一级行业板块、成交额趋势只读请求
|
||||||
- 原始载荷:未保存
|
- 原始载荷:未保存
|
||||||
|
|
||||||
## 结果
|
## 结果
|
||||||
|
|
||||||
- HTTP 请求成功。
|
- 三条业务接口均返回 HTTP `200`,响应状态均为 `fresh`,`source` 均为 `tdx`。
|
||||||
- TDX 业务 `ErrorId` 为 `0`。
|
- `/breadth`:成功汇总上证、深证和北证指数快照中的 `UpHome`、`DownHome`;`unchanged` 返回 `null`,未进行总数推算。
|
||||||
- 返回字段完整性满足当前协议基线。
|
- `/sectors`:成功读取一级行业目录并批量获取日行情,按涨跌幅排序返回前 8 项。
|
||||||
- 响应包含:股票代码键、K 线日期、开盘价、最高价、最低价、收盘价、成交额、成交量、复权因子和分页元数据。
|
- `/funds`:成功汇总三个交易所指数日行情 `Amount`,按万元转换为亿元并计算较前一交易日变化;该指标展示为“成交额趋势”,不宣称主力净流入。
|
||||||
- 未调用交易、账户、自选、写入或批量接口。
|
- 端到端耗时:市场宽度约 `0.11s`,一级行业约 `0.27s`,成交额趋势约 `0.12s`(单次本机冒烟,不代表生产 SLA)。
|
||||||
|
- 未调用交易、账户、自选、写入接口;未保存真实行情原始载荷。
|
||||||
|
|
||||||
## 脱敏与限制
|
## 脱敏与限制
|
||||||
|
|
||||||
- 报告不保存真实价格、成交量、家庭网络地址、Tailscale 地址、SSH 用户、密钥或完整响应。
|
- 报告不保存真实价格、成交量、家庭网络地址、Tailscale 地址、SSH 用户、密钥或完整响应。
|
||||||
- 本报告只证明当前开发机到 TDX 的只读协议可达,不证明 7×24 生产采集、商业再分发授权或所有板块/资金方法已经可用。
|
- 本报告只证明当前开发机到 TDX 的只读接口和本次映射可达,不证明 7×24 生产采集、商业再分发授权、主力净流入口径或盘中新闻接口已经可用。
|
||||||
- 后续接口实现必须继续以小批量只读请求验证板块、宽度和资金字段,并在映射测试中固定字段口径。
|
- 主力净流入和盘中新闻继续保留为补充数据源需求;后续接入必须单独记录来源、权限、口径和数据质量。
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
- 分析状态可以临时切换并保存默认值。
|
- 分析状态可以临时切换并保存默认值。
|
||||||
- 依据抽屉能够打开和关闭。
|
- 依据抽屉能够打开和关闭。
|
||||||
- 五个模块并行请求,一个模块失败不阻断其他模块。
|
- 五个模块并行请求,一个模块失败不阻断其他模块。
|
||||||
- 真实 TDX 指数和摘要响应可映射,未提供的方法返回 `unavailable`,不伪造行情。
|
- 真实 TDX 指数、市场宽度、一级行业板块和成交额趋势响应可映射;主力净流入与盘中新闻不以占位值伪造。
|
||||||
- 加载、部分数据、过期和错误状态具有明确文案。
|
- 加载、部分数据、过期和错误状态具有明确文案。
|
||||||
- 桌面和窄窗口不出现阻断核心内容的溢出。
|
- 桌面和窄窗口不出现阻断核心内容的溢出。
|
||||||
|
|
||||||
@@ -12,14 +12,14 @@
|
|||||||
|
|
||||||
- 前端 Vitest、typecheck、build:通过。
|
- 前端 Vitest、typecheck、build:通过。
|
||||||
- 后端 TDX 客户端、映射、服务、HTTP/OpenAPI 测试:通过。
|
- 后端 TDX 客户端、映射、服务、HTTP/OpenAPI 测试:通过。
|
||||||
- `curl` 冒烟:`/summary`、`/indices` 返回 `source=tdx,status=fresh`;`/breadth`、`/sectors`、`/funds` 返回结构化不可用状态。
|
- `curl` 冒烟:`/breadth`、`/sectors`、`/funds` 均返回 `source=tdx,status=fresh`;分别验证 `UpHome/DownHome` 汇总、一级行业排序和成交额单位转换。
|
||||||
- 浏览器验证:桌面默认视口和 390px 移动视口无横向溢出;综合/短线切换和分析引擎选择可用。
|
- 浏览器验证:桌面默认视口和 390px 移动视口无横向溢出;综合/短线切换和分析引擎选择可用。
|
||||||
|
|
||||||
## 安全、性能与残余风险
|
## 安全、性能与残余风险
|
||||||
|
|
||||||
- 安全:TDX 地址固定为本机回环桥接端口;请求超时、响应 1 MiB 上限、错误脱敏;只读行情,不接受用户输入拼接请求。
|
- 安全:TDX 地址固定为本机回环桥接端口;请求超时、响应 1 MiB 上限、错误脱敏;只读行情,不接受用户输入拼接请求。
|
||||||
- 性能:五个模块并行请求,单次 TDX 请求受超时保护;本切片不引入数据库、缓存或消息负载,未进行持续压测。
|
- 性能:五个模块并行请求,单次 TDX 请求受超时保护;本切片不引入数据库、缓存或消息负载,未进行持续压测。
|
||||||
- 残余风险:桥接层尚未提供宽度、板块和资金方法;详情页、依据抽屉和 AI 运行时需另立功能;市场数据授权和生产 7×24 稳定性未由本地冒烟证明。
|
- 残余风险:主力净流入字段当前桥接实测为零值,需权限和口径专项验证;盘中新闻仍需补充源;详情页、依据抽屉和 AI 运行时需另立功能;市场数据授权和生产 7×24 稳定性未由本地冒烟证明。
|
||||||
|
|
||||||
## 安全验证
|
## 安全验证
|
||||||
|
|
||||||
|
|||||||
@@ -72,18 +72,44 @@ export class BreadthDataDto {
|
|||||||
@ApiProperty({ type: Number })
|
@ApiProperty({ type: Number })
|
||||||
declining!: number;
|
declining!: number;
|
||||||
|
|
||||||
@ApiProperty({ type: Number })
|
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||||
unchanged!: number;
|
unchanged!: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SectorQuoteDto {
|
||||||
|
@ApiProperty({ type: String })
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||||
|
changePercent!: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ['up', 'down', 'flat'] })
|
||||||
|
direction!: QuoteDirection;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String })
|
||||||
|
tradeDate!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SectorDataDto {
|
export class SectorDataDto {
|
||||||
@ApiProperty({ type: [String] })
|
@ApiProperty({ type: [SectorQuoteDto] })
|
||||||
items!: string[];
|
items!: SectorQuoteDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class FundDataDto {
|
export class FundDataDto {
|
||||||
@ApiProperty({ type: [String] })
|
@ApiProperty({ type: Number })
|
||||||
items!: string[];
|
currentAmount!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: Number })
|
||||||
|
previousAmount!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: Number })
|
||||||
|
changePercent!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, example: '亿元' })
|
||||||
|
unit!: '亿元';
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SummaryResponseDto extends MarketOverviewMetaDto {
|
export class SummaryResponseDto extends MarketOverviewMetaDto {
|
||||||
|
|||||||
@@ -19,6 +19,63 @@ const indexResult = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const breadthSnapshot = (up: string, down: string) => ({
|
||||||
|
ErrorId: '0',
|
||||||
|
UpHome: up,
|
||||||
|
DownHome: down,
|
||||||
|
ItemNum: '0',
|
||||||
|
});
|
||||||
|
|
||||||
|
const sectorResult = {
|
||||||
|
ErrorId: '0',
|
||||||
|
Value: [
|
||||||
|
{ Code: '881319.SH', Name: '半导体' },
|
||||||
|
{ Code: '881355.SH', Name: '软件服务' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const sectorKlineResult = {
|
||||||
|
ErrorId: '0',
|
||||||
|
Value: {
|
||||||
|
'881319.SH': {
|
||||||
|
Date: ['20260817', '20260818'],
|
||||||
|
Open: ['100', '105'],
|
||||||
|
High: ['106', '110'],
|
||||||
|
Low: ['99', '104'],
|
||||||
|
Close: ['100', '108'],
|
||||||
|
Amount: ['1000', '1200'],
|
||||||
|
Volume: ['100', '120'],
|
||||||
|
},
|
||||||
|
'881355.SH': {
|
||||||
|
Date: ['20260817', '20260818'],
|
||||||
|
Open: ['100', '98'],
|
||||||
|
High: ['101', '99'],
|
||||||
|
Low: ['97', '95'],
|
||||||
|
Close: ['100', '96'],
|
||||||
|
Amount: ['1000', '900'],
|
||||||
|
Volume: ['100', '90'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const turnoverResult = {
|
||||||
|
ErrorId: '0',
|
||||||
|
Value: Object.fromEntries(
|
||||||
|
['000001.SH', '399001.SZ', '899050.BJ'].map((code) => [
|
||||||
|
code,
|
||||||
|
{
|
||||||
|
Date: ['20260817', '20260818'],
|
||||||
|
Open: ['100', '100'],
|
||||||
|
High: ['101', '101'],
|
||||||
|
Low: ['99', '99'],
|
||||||
|
Close: ['100', '100'],
|
||||||
|
Amount: ['100000', '120000'],
|
||||||
|
Volume: ['1000', '1100'],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
describe('市场概览服务', () => {
|
describe('市场概览服务', () => {
|
||||||
it('读取指数并返回统一快照元数据', async () => {
|
it('读取指数并返回统一快照元数据', async () => {
|
||||||
const client = { request: vi.fn().mockResolvedValue(indexResult) };
|
const client = { request: vi.fn().mockResolvedValue(indexResult) };
|
||||||
@@ -38,14 +95,69 @@ describe('市场概览服务', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('对未验证的市场宽度能力返回不可用而不是伪造数据', async () => {
|
it('汇总三个市场指数的上涨和下跌家数并保留未知平盘数', async () => {
|
||||||
const client = { request: vi.fn() };
|
const client = {
|
||||||
|
request: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(breadthSnapshot('10', '5'))
|
||||||
|
.mockResolvedValueOnce(breadthSnapshot('20', '15'))
|
||||||
|
.mockResolvedValueOnce(breadthSnapshot('3', '2')),
|
||||||
|
};
|
||||||
const service = new MarketOverviewService(client);
|
const service = new MarketOverviewService(client);
|
||||||
|
|
||||||
const response = await service.getBreadth();
|
const response = await service.getBreadth();
|
||||||
|
|
||||||
expect(response).toMatchObject({ source: 'tdx', status: 'unavailable', data: null });
|
expect(response).toMatchObject({
|
||||||
expect(response.warnings[0]).toContain('市场宽度');
|
source: 'tdx',
|
||||||
expect(client.request).not.toHaveBeenCalled();
|
status: 'fresh',
|
||||||
|
data: { advancing: 33, declining: 22, unchanged: null },
|
||||||
|
});
|
||||||
|
expect(client.request).toHaveBeenCalledWith(
|
||||||
|
'get_market_snapshot',
|
||||||
|
expect.objectContaining({ stock_code: '000001.SH' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('按一级行业涨跌幅排序板块并保留 TDX 代码', async () => {
|
||||||
|
const client = {
|
||||||
|
request: vi.fn().mockResolvedValueOnce(sectorResult).mockResolvedValueOnce(sectorKlineResult),
|
||||||
|
};
|
||||||
|
const service = new MarketOverviewService(client);
|
||||||
|
|
||||||
|
const response = await service.getSectors();
|
||||||
|
|
||||||
|
expect(response).toMatchObject({
|
||||||
|
source: 'tdx',
|
||||||
|
status: 'fresh',
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
expect.objectContaining({ code: '881319.SH', name: '半导体', changePercent: 8 }),
|
||||||
|
expect.objectContaining({ code: '881355.SH', name: '软件服务', changePercent: -4 }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(client.request).toHaveBeenNthCalledWith(1, 'get_stock_list', {
|
||||||
|
market: '16',
|
||||||
|
list_type: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('按交易所指数成交额计算亿元级趋势而不宣称主力净流入', async () => {
|
||||||
|
const client = { request: vi.fn().mockResolvedValue(turnoverResult) };
|
||||||
|
const service = new MarketOverviewService(client);
|
||||||
|
|
||||||
|
const response = await service.getFunds();
|
||||||
|
|
||||||
|
expect(response).toMatchObject({
|
||||||
|
source: 'tdx',
|
||||||
|
status: 'fresh',
|
||||||
|
data: {
|
||||||
|
currentAmount: 36,
|
||||||
|
previousAmount: 30,
|
||||||
|
changePercent: 20,
|
||||||
|
unit: '亿元',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(response.warnings).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,9 +8,19 @@ import type {
|
|||||||
SummaryDataDto,
|
SummaryDataDto,
|
||||||
} from './market-overview.dto';
|
} from './market-overview.dto';
|
||||||
import { CORE_INDEXES } from './tdx/tdx.types';
|
import { CORE_INDEXES } from './tdx/tdx.types';
|
||||||
import { mapIndexQuotes } from './tdx/tdx.mapper';
|
import {
|
||||||
|
mapBreadthSnapshots,
|
||||||
|
mapIndexQuotes,
|
||||||
|
mapSectorQuotes,
|
||||||
|
mapTurnoverTrend,
|
||||||
|
} from './tdx/tdx.mapper';
|
||||||
import { TdxClientError, type TdxClient } from './tdx/tdx.client';
|
import { TdxClientError, type TdxClient } from './tdx/tdx.client';
|
||||||
import type { TdxMarketDataResult } from './tdx/tdx.types';
|
import type {
|
||||||
|
TdxMarketDataResult,
|
||||||
|
TdxMarketSnapshotResult,
|
||||||
|
TdxStockListResult,
|
||||||
|
} from './tdx/tdx.types';
|
||||||
|
import { BREADTH_INDEXES, TURNOVER_INDEXES } from './tdx/tdx.types';
|
||||||
|
|
||||||
export const TDX_CLIENT_PORT = Symbol('TDX_CLIENT_PORT');
|
export const TDX_CLIENT_PORT = Symbol('TDX_CLIENT_PORT');
|
||||||
|
|
||||||
@@ -32,10 +42,6 @@ function createSnapshotMeta(): Pick<
|
|||||||
return { snapshotId, asOf, source: 'tdx' };
|
return { snapshotId, asOf, source: 'tdx' };
|
||||||
}
|
}
|
||||||
|
|
||||||
function unavailable<TData>(warning: string): MarketOverviewResponse<TData> {
|
|
||||||
return { ...createSnapshotMeta(), status: 'unavailable', data: null, warnings: [warning] };
|
|
||||||
}
|
|
||||||
|
|
||||||
@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) {}
|
||||||
@@ -89,24 +95,110 @@ export class MarketOverviewService {
|
|||||||
? '市场维持偏强震荡'
|
? '市场维持偏强震荡'
|
||||||
: '市场维持偏弱震荡',
|
: '市场维持偏弱震荡',
|
||||||
marketStatus,
|
marketStatus,
|
||||||
volumeStatus: '成交量口径待接入',
|
volumeStatus: '成交额趋势见下方',
|
||||||
sentimentStatus: '市场情绪口径待接入',
|
sentimentStatus: '涨跌家数见下方',
|
||||||
drivers: changes.length ? [`核心指数平均涨跌幅 ${averageChange}%`] : ['核心指数数据不足'],
|
drivers: changes.length ? [`核心指数平均涨跌幅 ${averageChange}%`] : ['核心指数数据不足'],
|
||||||
risks: ['市场宽度、板块和资金指标尚未由当前 TDX 桥接层提供'],
|
risks: ['主力净流入和盘中新闻仍待补充数据源验证'],
|
||||||
};
|
};
|
||||||
return { ...indices, data: summary };
|
return { ...indices, data: summary };
|
||||||
}
|
}
|
||||||
|
|
||||||
async getBreadth(): Promise<MarketOverviewResponse<BreadthDataDto>> {
|
async getBreadth(): Promise<MarketOverviewResponse<BreadthDataDto>> {
|
||||||
return unavailable<BreadthDataDto>('当前 TDX 桥接层未提供市场宽度方法');
|
const meta = createSnapshotMeta();
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
BREADTH_INDEXES.map((item) =>
|
||||||
|
this.tdxClient.request<TdxMarketSnapshotResult>('get_market_snapshot', {
|
||||||
|
stock_code: item.code,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const snapshots = results.flatMap((result) =>
|
||||||
|
result.status === 'fulfilled' ? [result.value] : [],
|
||||||
|
);
|
||||||
|
if (!snapshots.length) {
|
||||||
|
return {
|
||||||
|
...meta,
|
||||||
|
status: 'unavailable',
|
||||||
|
data: null,
|
||||||
|
warnings: [
|
||||||
|
this.toWarning(
|
||||||
|
results[0]?.status === 'rejected' ? results[0].reason : undefined,
|
||||||
|
'市场宽度',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const status = snapshots.length === BREADTH_INDEXES.length ? 'fresh' : 'partial';
|
||||||
|
return {
|
||||||
|
...meta,
|
||||||
|
status,
|
||||||
|
data: mapBreadthSnapshots(snapshots),
|
||||||
|
warnings: status === 'partial' ? ['部分市场指数未返回涨跌家数'] : [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSectors(): Promise<MarketOverviewResponse<SectorDataDto>> {
|
async getSectors(): Promise<MarketOverviewResponse<SectorDataDto>> {
|
||||||
return unavailable<SectorDataDto>('当前 TDX 桥接层未提供板块排行方法');
|
const meta = createSnapshotMeta();
|
||||||
|
try {
|
||||||
|
const catalog = await this.tdxClient.request<TdxStockListResult>('get_stock_list', {
|
||||||
|
market: '16',
|
||||||
|
list_type: 1,
|
||||||
|
});
|
||||||
|
const definitions = catalog.Value.map(({ Code: code, Name: name }) => ({ code, name }));
|
||||||
|
const result = await this.tdxClient.request<TdxMarketDataResult>('get_market_data', {
|
||||||
|
stock_list: definitions.map((item) => item.code),
|
||||||
|
count: 2,
|
||||||
|
dividend_type: 'none',
|
||||||
|
period: '1d',
|
||||||
|
});
|
||||||
|
const mappedItems = mapSectorQuotes(result, definitions);
|
||||||
|
const items = mappedItems.slice(0, 8);
|
||||||
|
const status =
|
||||||
|
mappedItems.length === definitions.length
|
||||||
|
? 'fresh'
|
||||||
|
: mappedItems.length
|
||||||
|
? 'partial'
|
||||||
|
: 'unavailable';
|
||||||
|
return {
|
||||||
|
...meta,
|
||||||
|
status,
|
||||||
|
data: items.length ? { items } : null,
|
||||||
|
warnings:
|
||||||
|
status === 'fresh'
|
||||||
|
? []
|
||||||
|
: [status === 'partial' ? '部分一级行业未返回有效行情' : '一级行业行情不可用'],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
...meta,
|
||||||
|
status: 'unavailable',
|
||||||
|
data: null,
|
||||||
|
warnings: [this.toWarning(error, '板块行情')],
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFunds(): Promise<MarketOverviewResponse<FundDataDto>> {
|
async getFunds(): Promise<MarketOverviewResponse<FundDataDto>> {
|
||||||
return unavailable<FundDataDto>('当前 TDX 桥接层未提供资金趋势方法');
|
const meta = createSnapshotMeta();
|
||||||
|
try {
|
||||||
|
const result = await this.tdxClient.request<TdxMarketDataResult>('get_market_data', {
|
||||||
|
stock_list: TURNOVER_INDEXES.map((item) => item.code),
|
||||||
|
count: 2,
|
||||||
|
dividend_type: 'none',
|
||||||
|
period: '1d',
|
||||||
|
});
|
||||||
|
const data = mapTurnoverTrend(result, TURNOVER_INDEXES);
|
||||||
|
if (!data)
|
||||||
|
return { ...meta, status: 'unavailable', data: null, warnings: ['成交额趋势数据不完整'] };
|
||||||
|
return { ...meta, status: 'fresh', data, warnings: [] };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
...meta,
|
||||||
|
status: 'unavailable',
|
||||||
|
data: null,
|
||||||
|
warnings: [this.toWarning(error, '成交额趋势')],
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private toWarning(error: unknown, subject: string): string {
|
private toWarning(error: unknown, subject: string): string {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { mapIndexQuotes } from './tdx.mapper';
|
import { mapBreadthSnapshots, mapIndexQuotes, mapTurnoverTrend } from './tdx.mapper';
|
||||||
|
|
||||||
describe('TDX 行情映射', () => {
|
describe('TDX 行情映射', () => {
|
||||||
it('将 K 线数组转换为 A 股红涨绿跌的指数报价', () => {
|
it('将 K 线数组转换为 A 股红涨绿跌的指数报价', () => {
|
||||||
@@ -55,4 +55,35 @@ describe('TDX 行情映射', () => {
|
|||||||
|
|
||||||
expect(result[0]).toMatchObject({ price: 2105, changePercent: null, direction: 'flat' });
|
expect(result[0]).toMatchObject({ price: 2105, changePercent: null, direction: 'flat' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('市场宽度映射只汇总可用的上涨和下跌家数', () => {
|
||||||
|
expect(
|
||||||
|
mapBreadthSnapshots([
|
||||||
|
{ UpHome: '10', DownHome: '5' },
|
||||||
|
{ UpHome: '2', DownHome: '3' },
|
||||||
|
]),
|
||||||
|
).toEqual({ advancing: 12, declining: 8, unchanged: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('成交额映射将 TDX 万元转换为亿元并计算环比', () => {
|
||||||
|
expect(
|
||||||
|
mapTurnoverTrend(
|
||||||
|
{
|
||||||
|
ErrorId: '0',
|
||||||
|
Value: {
|
||||||
|
'000001.SH': {
|
||||||
|
Date: ['20260817', '20260818'],
|
||||||
|
Open: ['1', '1'],
|
||||||
|
High: ['1', '1'],
|
||||||
|
Low: ['1', '1'],
|
||||||
|
Close: ['1', '1'],
|
||||||
|
Amount: ['100000', '120000'],
|
||||||
|
Volume: ['1', '1'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[{ code: '000001.SH', name: '上证市场' }],
|
||||||
|
),
|
||||||
|
).toEqual({ currentAmount: 12, previousAmount: 10, changePercent: 20, unit: '亿元' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import type { IndexQuoteDto } from '../market-overview.dto';
|
import type {
|
||||||
|
BreadthDataDto,
|
||||||
|
FundDataDto,
|
||||||
|
IndexQuoteDto,
|
||||||
|
SectorQuoteDto,
|
||||||
|
} from '../market-overview.dto';
|
||||||
import type { IndexDefinition, TdxMarketDataResult, TdxKlineSeries } from './tdx.types';
|
import type { IndexDefinition, TdxMarketDataResult, TdxKlineSeries } from './tdx.types';
|
||||||
|
|
||||||
function toNumber(value: string | undefined): number | null {
|
function toNumber(value: string | undefined): number | null {
|
||||||
@@ -46,3 +51,51 @@ export function mapIndexQuotes(
|
|||||||
return quote ? [quote] : [];
|
return quote ? [quote] : [];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapTurnoverTrend(
|
||||||
|
result: TdxMarketDataResult,
|
||||||
|
definitions: readonly IndexDefinition[],
|
||||||
|
): FundDataDto | null {
|
||||||
|
let currentAmount = 0;
|
||||||
|
let previousAmount = 0;
|
||||||
|
let validSeries = 0;
|
||||||
|
|
||||||
|
for (const definition of definitions) {
|
||||||
|
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 (!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: '亿元',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,20 @@ export interface TdxMarketDataResult {
|
|||||||
Value: Record<string, TdxKlineSeries>;
|
Value: Record<string, TdxKlineSeries>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TdxMarketSnapshotResult {
|
||||||
|
ErrorId: string | number;
|
||||||
|
UpHome?: string;
|
||||||
|
DownHome?: string;
|
||||||
|
Inside?: string;
|
||||||
|
Outside?: string;
|
||||||
|
Amount?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TdxStockListResult {
|
||||||
|
ErrorId: string | number;
|
||||||
|
Value: Array<{ Code: string; Name: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IndexDefinition {
|
export interface IndexDefinition {
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -25,3 +39,17 @@ export const CORE_INDEXES: readonly IndexDefinition[] = [
|
|||||||
{ code: '399001.SZ', name: '深证成指' },
|
{ code: '399001.SZ', name: '深证成指' },
|
||||||
{ code: '399006.SZ', name: '创业板指' },
|
{ code: '399006.SZ', name: '创业板指' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** TDX 快照对指数提供上涨/下跌家数,三个市场相加后作为首屏市场宽度口径。 */
|
||||||
|
export const BREADTH_INDEXES: readonly IndexDefinition[] = [
|
||||||
|
{ code: '000001.SH', name: '上证市场' },
|
||||||
|
{ code: '399001.SZ', name: '深证市场' },
|
||||||
|
{ code: '899050.BJ', name: '北证市场' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 首屏资金模块使用交易所指数成交额,避免把成交额趋势误称为主力净流入。 */
|
||||||
|
export const TURNOVER_INDEXES: readonly IndexDefinition[] = [
|
||||||
|
{ code: '000001.SH', name: '上证市场' },
|
||||||
|
{ code: '399001.SZ', name: '深证市场' },
|
||||||
|
{ code: '899050.BJ', name: '北证市场' },
|
||||||
|
];
|
||||||
|
|||||||
@@ -72,13 +72,32 @@ describe('市场概览 HTTP API', () => {
|
|||||||
expect(JSON.stringify(response.body)).not.toContain('127.0.0.1');
|
expect(JSON.stringify(response.body)).not.toContain('127.0.0.1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('未提供的板块能力返回可读的不可用状态', async () => {
|
it('板块接口返回可点击的 TDX 一级行业对象', async () => {
|
||||||
|
const client = {
|
||||||
|
request: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ErrorId: '0',
|
||||||
|
Value: [{ Code: '881319.SH', Name: '半导体' }],
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ErrorId: '0',
|
||||||
|
Value: {
|
||||||
|
'881319.SH': {
|
||||||
|
Date: ['20260817', '20260818'],
|
||||||
|
Open: ['100', '101'],
|
||||||
|
High: ['102', '103'],
|
||||||
|
Low: ['99', '100'],
|
||||||
|
Close: ['100', '102'],
|
||||||
|
Amount: ['1000', '1200'],
|
||||||
|
Volume: ['100', '120'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
controllers: [MarketOverviewController],
|
controllers: [MarketOverviewController],
|
||||||
providers: [
|
providers: [MarketOverviewService, { provide: TDX_CLIENT_PORT, useValue: client }],
|
||||||
MarketOverviewService,
|
|
||||||
{ provide: TDX_CLIENT_PORT, useValue: { request: vi.fn() } },
|
|
||||||
],
|
|
||||||
}).compile();
|
}).compile();
|
||||||
app = moduleRef.createNestApplication();
|
app = moduleRef.createNestApplication();
|
||||||
app.setGlobalPrefix('api/v1');
|
app.setGlobalPrefix('api/v1');
|
||||||
@@ -88,7 +107,13 @@ describe('市场概览 HTTP API', () => {
|
|||||||
.get('/api/v1/market-overview/sectors')
|
.get('/api/v1/market-overview/sectors')
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(response.body).toMatchObject({ source: 'tdx', status: 'unavailable', data: null });
|
expect(response.body).toMatchObject({
|
||||||
expect(response.body.warnings[0]).toContain('板块排行');
|
source: 'tdx',
|
||||||
|
status: 'fresh',
|
||||||
|
data: {
|
||||||
|
items: [expect.objectContaining({ code: '881319.SH', name: '半导体', changePercent: 2 })],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(response.body)).not.toContain('127.0.0.1');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"advancing": { "type": "number" },
|
"advancing": { "type": "number" },
|
||||||
"declining": { "type": "number" },
|
"declining": { "type": "number" },
|
||||||
"unchanged": { "type": "number" }
|
"unchanged": { "nullable": true, "type": "number" }
|
||||||
},
|
},
|
||||||
"required": ["advancing", "declining", "unchanged"],
|
"required": ["advancing", "declining"],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
"BreadthResponseDto": {
|
"BreadthResponseDto": {
|
||||||
@@ -42,9 +42,12 @@
|
|||||||
},
|
},
|
||||||
"FundDataDto": {
|
"FundDataDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"items": { "items": { "type": "string" }, "type": "array" }
|
"changePercent": { "type": "number" },
|
||||||
|
"currentAmount": { "type": "number" },
|
||||||
|
"previousAmount": { "type": "number" },
|
||||||
|
"unit": { "example": "亿元", "type": "string" }
|
||||||
},
|
},
|
||||||
"required": ["items"],
|
"required": ["currentAmount", "previousAmount", "changePercent", "unit"],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
"FundsResponseDto": {
|
"FundsResponseDto": {
|
||||||
@@ -130,11 +133,25 @@
|
|||||||
},
|
},
|
||||||
"SectorDataDto": {
|
"SectorDataDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"items": { "items": { "type": "string" }, "type": "array" }
|
"items": {
|
||||||
|
"items": { "$ref": "#/components/schemas/SectorQuoteDto" },
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"required": ["items"],
|
"required": ["items"],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"SectorQuoteDto": {
|
||||||
|
"properties": {
|
||||||
|
"changePercent": { "nullable": true, "type": "number" },
|
||||||
|
"code": { "type": "string" },
|
||||||
|
"direction": { "enum": ["up", "down", "flat"], "type": "string" },
|
||||||
|
"name": { "type": "string" },
|
||||||
|
"tradeDate": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["code", "name", "direction", "tradeDate"],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"SectorsResponseDto": {
|
"SectorsResponseDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"asOf": { "format": "date-time", "type": "string" },
|
"asOf": { "format": "date-time", "type": "string" },
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export interface components {
|
|||||||
BreadthDataDto: {
|
BreadthDataDto: {
|
||||||
advancing: number;
|
advancing: number;
|
||||||
declining: number;
|
declining: number;
|
||||||
unchanged: number;
|
unchanged?: number | null;
|
||||||
};
|
};
|
||||||
BreadthResponseDto: {
|
BreadthResponseDto: {
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
@@ -126,7 +126,11 @@ export interface components {
|
|||||||
status: 'healthy' | 'unavailable' | 'disabled';
|
status: 'healthy' | 'unavailable' | 'disabled';
|
||||||
};
|
};
|
||||||
FundDataDto: {
|
FundDataDto: {
|
||||||
items: string[];
|
changePercent: number;
|
||||||
|
currentAmount: number;
|
||||||
|
previousAmount: number;
|
||||||
|
/** @example 亿元 */
|
||||||
|
unit: string;
|
||||||
};
|
};
|
||||||
FundsResponseDto: {
|
FundsResponseDto: {
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
@@ -177,7 +181,15 @@ export interface components {
|
|||||||
warnings: string[];
|
warnings: string[];
|
||||||
};
|
};
|
||||||
SectorDataDto: {
|
SectorDataDto: {
|
||||||
items: string[];
|
items: components['schemas']['SectorQuoteDto'][];
|
||||||
|
};
|
||||||
|
SectorQuoteDto: {
|
||||||
|
changePercent?: number | null;
|
||||||
|
code: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
direction: 'up' | 'down' | 'flat';
|
||||||
|
name: string;
|
||||||
|
tradeDate: string;
|
||||||
};
|
};
|
||||||
SectorsResponseDto: {
|
SectorsResponseDto: {
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
|
|||||||
@@ -472,12 +472,54 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sector-list button {
|
.sector-list button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-width: 112px;
|
||||||
|
gap: 10px;
|
||||||
padding: 7px 9px;
|
padding: 7px 9px;
|
||||||
color: #4c5966;
|
color: #4c5966;
|
||||||
background: #f3f7f7;
|
background: #f3f7f7;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sector-list button strong {
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sector-list button[data-direction='up'] strong,
|
||||||
|
.turnover-trend [data-direction='up'] {
|
||||||
|
color: #c33f3f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sector-list button[data-direction='down'] strong,
|
||||||
|
.turnover-trend [data-direction='down'] {
|
||||||
|
color: #19846e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.turnover-trend {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.turnover-trend div {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.turnover-trend strong {
|
||||||
|
color: #1c2a35;
|
||||||
|
font-size: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.turnover-trend small,
|
||||||
|
.turnover-trend p {
|
||||||
|
color: #7d8893;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.overview-grid-top,
|
.overview-grid-top,
|
||||||
.overview-grid-bottom {
|
.overview-grid-bottom {
|
||||||
|
|||||||
@@ -8,16 +8,28 @@ defineProps<{ response: FundsResponse | null; loading: boolean }>();
|
|||||||
<section class="overview-panel" aria-labelledby="funds-title">
|
<section class="overview-panel" aria-labelledby="funds-title">
|
||||||
<div class="panel-heading">
|
<div class="panel-heading">
|
||||||
<div>
|
<div>
|
||||||
<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">数据口径</span>
|
<span class="panel-meta">TDX · 亿元</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 class="module-placeholder">暂无资金趋势数据</div>
|
<div v-else-if="response?.data" class="turnover-trend">
|
||||||
|
<div>
|
||||||
|
<span>三市成交额</span>
|
||||||
|
<strong>{{ response.data.currentAmount.toLocaleString('zh-CN') }}</strong>
|
||||||
|
<small>{{ response.data.unit }}</small>
|
||||||
|
</div>
|
||||||
|
<p :data-direction="response.data.changePercent >= 0 ? 'up' : 'down'">
|
||||||
|
较前一交易日
|
||||||
|
{{ response.data.changePercent > 0 ? '+' : ''
|
||||||
|
}}{{ response.data.changePercent.toFixed(2) }}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="module-placeholder">暂无成交额趋势数据</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ defineProps<{ response: BreadthResponse | null; loading: boolean }>();
|
|||||||
<span>下跌</span><strong class="negative">{{ response.data.declining }}</strong>
|
<span>下跌</span><strong class="negative">{{ response.data.declining }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>平盘</span><strong>{{ response.data.unchanged }}</strong>
|
<span>平盘</span><strong>{{ response.data.unchanged ?? '—' }}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="module-placeholder">暂无宽度数据</div>
|
<div v-else class="module-placeholder">暂无宽度数据</div>
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import FundTrend from './FundTrend.vue';
|
||||||
|
import MarketBreadth from './MarketBreadth.vue';
|
||||||
|
import SectorOverview from './SectorOverview.vue';
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
snapshotId: '20260818-150000',
|
||||||
|
asOf: '2026-08-18T15:00:00.000Z',
|
||||||
|
source: 'tdx' as const,
|
||||||
|
status: 'fresh' as const,
|
||||||
|
warnings: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('市场概览真实数据面板', () => {
|
||||||
|
it('市场宽度对未知平盘家数显示占位符而不是零', () => {
|
||||||
|
const wrapper = mount(MarketBreadth, {
|
||||||
|
props: {
|
||||||
|
loading: false,
|
||||||
|
response: { ...meta, data: { advancing: 2121, declining: 3292, unchanged: null } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('2121');
|
||||||
|
expect(wrapper.text()).toContain('3292');
|
||||||
|
expect(wrapper.text()).toContain('平盘—');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('板块展示名称和涨跌幅并以代码触发详情入口', async () => {
|
||||||
|
const wrapper = mount(SectorOverview, {
|
||||||
|
props: {
|
||||||
|
loading: false,
|
||||||
|
response: {
|
||||||
|
...meta,
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
code: '881319.SH',
|
||||||
|
name: '半导体',
|
||||||
|
changePercent: 2.56,
|
||||||
|
direction: 'up',
|
||||||
|
tradeDate: '20260818',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('半导体');
|
||||||
|
expect(wrapper.text()).toContain('+2.56%');
|
||||||
|
await wrapper.get('button').trigger('click');
|
||||||
|
expect(wrapper.emitted('select')).toEqual([['881319.SH']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('资金模块明确展示成交额和与上一交易日的变化', () => {
|
||||||
|
const wrapper = mount(FundTrend, {
|
||||||
|
props: {
|
||||||
|
loading: false,
|
||||||
|
response: {
|
||||||
|
...meta,
|
||||||
|
data: {
|
||||||
|
currentAmount: 12345.67,
|
||||||
|
previousAmount: 11000,
|
||||||
|
changePercent: 12.23,
|
||||||
|
unit: '亿元' as const,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('成交额趋势');
|
||||||
|
expect(wrapper.text()).toContain('12,345.67');
|
||||||
|
expect(wrapper.text()).toContain('较前一交易日 +12.23%');
|
||||||
|
expect(wrapper.text()).not.toContain('主力净流入');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,11 +22,20 @@ const emit = defineEmits<{ select: [sector: string] }>();
|
|||||||
<div v-else-if="response?.data?.items.length" class="sector-list">
|
<div v-else-if="response?.data?.items.length" class="sector-list">
|
||||||
<button
|
<button
|
||||||
v-for="sector in response.data.items"
|
v-for="sector in response.data.items"
|
||||||
:key="sector"
|
:key="sector.code"
|
||||||
type="button"
|
type="button"
|
||||||
@click="emit('select', sector)"
|
:data-direction="sector.direction"
|
||||||
|
:title="`进入${sector.name}详细分析`"
|
||||||
|
@click="emit('select', sector.code)"
|
||||||
>
|
>
|
||||||
{{ sector }}
|
<span>{{ sector.name }}</span>
|
||||||
|
<strong>
|
||||||
|
{{
|
||||||
|
sector.changePercent == null
|
||||||
|
? '—'
|
||||||
|
: `${sector.changePercent > 0 ? '+' : ''}${sector.changePercent.toFixed(2)}%`
|
||||||
|
}}
|
||||||
|
</strong>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="module-placeholder">暂无板块数据</div>
|
<div v-else class="module-placeholder">暂无板块数据</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user