diff --git a/docs/features/FEAT-MO-001-market-overview/design.md b/docs/features/FEAT-MO-001-market-overview/design.md index fa42c15..414607b 100644 --- a/docs/features/FEAT-MO-001-market-overview/design.md +++ b/docs/features/FEAT-MO-001-market-overview/design.md @@ -5,8 +5,11 @@ ## 数据可用性 - `summary` 和 `indices`:使用 TDX K 线真实响应,展示来源、快照时间和指数涨跌。 -- `breadth`、`sectors`、`funds`:桥接层暂未注册对应方法,使用 `unavailable` 响应和明确原因。 -- 页面不把不可用指标降级为模拟值;后续补齐桥接方法时只替换后端映射,不改变前端契约。 +- `breadth`:使用 TDX 指数快照的 `UpHome`、`DownHome` 汇总上证、深证和北证市场宽度;未返回的 `unchanged` 明确为 `null`,不得用总数推算。 +- `sectors`:使用 TDX 系统分类中的一级行业(`get_stock_list(market=16)`)和批量日行情,按涨跌幅排序后展示重点强弱板块。 +- `funds`:模块名称和口径为“成交额趋势”,使用核心交易所指数日行情的 `Amount` 汇总当前与前一交易日成交额;这不是主力净流入。 +- 主力净流入和盘中新闻不在本次 TDX 首屏主链路中,后续通过独立补充数据源适配器接入,并在响应中标记来源与数据质量。 +- 页面不把缺失或权限不足的指标降级为模拟值;单个来源失败只影响对应模块。 ## 运行边界 diff --git a/docs/features/FEAT-MO-001-market-overview/development-summary.md b/docs/features/FEAT-MO-001-market-overview/development-summary.md index 0ef6e29..a36ae21 100644 --- a/docs/features/FEAT-MO-001-market-overview/development-summary.md +++ b/docs/features/FEAT-MO-001-market-overview/development-summary.md @@ -8,7 +8,7 @@ - 数据源从早期“确定性模拟快照”调整为 TDX 只读直连,Nest API 访问本机 `127.0.0.1:17710`,Vue 不直接访问 TDX。 - API 按摘要、指数、市场宽度、板块和资金拆分,避免把整个首屏塞入单个接口。 -- 当前 TDX 桥接层仅确认 `get_market_data` 可用;宽度、板块和资金方法未注册,因此对应模块明确返回 `unavailable`。 +- 当前 TDX 桥接已确认 `get_market_snapshot`、`get_stock_list` 和批量 `get_market_data` 可用;市场宽度、一级行业板块和成交额趋势改为真实 TDX 映射。主力净流入字段虽存在但当前实测为零值,盘中新闻无官方行情接口,因此两者延期到补充源功能。 - MySQL、Redis、RocketMQ 和 AI 仍不进入本切片运行链路;它们保留为后续基础设施边界。 ## 实际完成需求 @@ -18,6 +18,7 @@ - 完成结论、驱动与风险、核心指数、市场宽度、板块、资金趋势模块,以及加载/错误/不可用状态。 - 完成分析视角和分析引擎切换,本地保存用户偏好。 - 真实 TDX 指数和摘要已通过本机隧道冒烟;未用指数或离线样本伪造缺失指标。 +- 真实 TDX 市场宽度、一级行业和成交额趋势已通过本机桥接冒烟;资金模块明确使用成交额口径,不宣称主力净流入。 ## 修复缺陷 @@ -29,7 +30,7 @@ ## 未完成与延期项 -- TDX 桥接层补齐市场宽度、板块排行、资金趋势方法后再实现真实图表和口径。 +- 主力净流入的权限/数据质量验证、盘中新闻补充源、真实图表和口径仍延期。 - “查看依据”抽屉、指数/板块详情页、AI 请求和用户体系另立功能。 - 本地验证不代表生产 7×24 采集稳定性、商业授权或公网部署安全性。 @@ -41,7 +42,7 @@ ## 验证证据 - 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 测试通过。 - `pnpm verify:e2e`:4/4 通过(桌面和移动端各覆盖页面与健康契约)。 - 浏览器桌面与 390px 移动视口验证无横向溢出,视角与引擎切换可用。 diff --git a/docs/features/FEAT-MO-001-market-overview/implementation-plan.md b/docs/features/FEAT-MO-001-market-overview/implementation-plan.md index e9248f2..c7da378 100644 --- a/docs/features/FEAT-MO-001-market-overview/implementation-plan.md +++ b/docs/features/FEAT-MO-001-market-overview/implementation-plan.md @@ -6,4 +6,6 @@ 4. ~~实现指数、宽度、板块和资金模块;不可用指标使用结构化状态。~~ 已完成。 5. ~~实现分析偏好菜单和本地持久化。~~ 已完成;依据抽屉和详情页入口延期至独立功能。 6. ~~完成单元测试、类型检查、构建、接口冒烟、浏览器桌面/移动端视觉验证。~~ 已完成。 -7. 补齐 TDX 桥接层的宽度、板块和资金方法后,再单独更新对应数据口径和图表需求。 +7. ~~补齐 TDX 市场宽度、一级行业板块和成交额趋势映射。~~ 已确认官方接口和本机桥接可用;主力净流入与盘中新闻保留为补充源需求。 +8. 更新 DTO、OpenAPI 类型和前端组件,使真实数据状态、单位和 `unchanged=null` 口径可见。 +9. 完成 TDX 真实响应夹具、错误隔离、类型、构建和浏览器视觉验证;Review 后再收口功能档案。 diff --git a/docs/features/FEAT-MO-001-market-overview/prd.md b/docs/features/FEAT-MO-001-market-overview/prd.md index a52c989..5f95581 100644 --- a/docs/features/FEAT-MO-001-market-overview/prd.md +++ b/docs/features/FEAT-MO-001-market-overview/prd.md @@ -93,3 +93,4 @@ A 股使用红涨绿跌。成交额统一展示为亿元,比例明确使用百 - 0.1:建立首版 PRD,补充结论优先、分析偏好和异常状态。 - 0.2:根据 TDX 实连验证修正数据源、可用性边界和未接入基础设施范围。 +- 0.3:核对官方行情接口后确认市场宽度、一级行业板块和成交额趋势可由 TDX 直接提供;主力净流入与盘中新闻保留补充源边界。 diff --git a/docs/features/FEAT-MO-001-market-overview/requirements.md b/docs/features/FEAT-MO-001-market-overview/requirements.md index d67fbdc..1973aeb 100644 --- a/docs/features/FEAT-MO-001-market-overview/requirements.md +++ b/docs/features/FEAT-MO-001-market-overview/requirements.md @@ -2,11 +2,12 @@ 首屏为“市场概览”模块中的综合概览,采用结论优先。市场概览内部通过轻量分析状态入口切换综合、短线和中长线视角,并支持规则、AI、混合与对照分析。 -当前切片开发 Vue 3 可交互首屏,Nest API 通过 TDX 只读桥接层提供真实指数和摘要;市场宽度、板块和资金接口保留结构化不可用状态,不使用离线样本。不接用户体系,也不启用 MySQL、Redis、RocketMQ 或 AI 运行链路。 +当前切片开发 Vue 3 可交互首屏,Nest API 通过 TDX 只读桥接层提供真实指数、市场宽度、板块和成交额趋势;主力净流入与盘中新闻不作为本次 TDX 主链路指标,不使用离线样本。不接用户体系,也不启用 MySQL、Redis、RocketMQ 或 AI 运行链路。 ## 本次实际范围 - 已完成综合、短线、中长线视角切换,以及规则、AI、混合、对照偏好选择;偏好只保存于浏览器本地。 - 已完成按摘要、指数、宽度、板块、资金拆分的五个 HTTP 接口和前端 API 封装。 - 已完成 TDX 请求超时、响应大小限制、错误脱敏和模块级失败隔离。 +- 已确认并接入 TDX 市场宽度、一级行业板块和成交额趋势;资金模块明确不等同于主力净流入,`unchanged` 无可靠总数时返回 `null`。 - 详情页和“查看依据”抽屉只保留入口,另立需求实现,不在本次发布范围内。 diff --git a/docs/features/FEAT-MO-001-market-overview/tdx-smoke-report.md b/docs/features/FEAT-MO-001-market-overview/tdx-smoke-report.md index 4e7cbaa..d1342e3 100644 --- a/docs/features/FEAT-MO-001-market-overview/tdx-smoke-report.md +++ b/docs/features/FEAT-MO-001-market-overview/tdx-smoke-report.md @@ -1,22 +1,24 @@ # FEAT-MO-001 TDX 只读冒烟报告 -- 验证时间:2026-08-18 13:04 CST -- 网络场景:外出 Tailscale + SSH 隧道 +- 验证时间:2026-08-18 17:21 CST +- 网络场景:本机回环桥接(TDX 服务可达) - 本机业务入口:`127.0.0.1:17710` -- 请求方式:HTTP POST JSON -- 请求类型:单只公开行情 K 线只读请求 +- API 入口:`127.0.0.1:3000/api/v1/market-overview` +- 请求方式:HTTP GET(业务 API)及桥接层 HTTP POST JSON +- 请求类型:市场宽度、一级行业板块、成交额趋势只读请求 - 原始载荷:未保存 ## 结果 -- HTTP 请求成功。 -- TDX 业务 `ErrorId` 为 `0`。 -- 返回字段完整性满足当前协议基线。 -- 响应包含:股票代码键、K 线日期、开盘价、最高价、最低价、收盘价、成交额、成交量、复权因子和分页元数据。 -- 未调用交易、账户、自选、写入或批量接口。 +- 三条业务接口均返回 HTTP `200`,响应状态均为 `fresh`,`source` 均为 `tdx`。 +- `/breadth`:成功汇总上证、深证和北证指数快照中的 `UpHome`、`DownHome`;`unchanged` 返回 `null`,未进行总数推算。 +- `/sectors`:成功读取一级行业目录并批量获取日行情,按涨跌幅排序返回前 8 项。 +- `/funds`:成功汇总三个交易所指数日行情 `Amount`,按万元转换为亿元并计算较前一交易日变化;该指标展示为“成交额趋势”,不宣称主力净流入。 +- 端到端耗时:市场宽度约 `0.11s`,一级行业约 `0.27s`,成交额趋势约 `0.12s`(单次本机冒烟,不代表生产 SLA)。 +- 未调用交易、账户、自选、写入接口;未保存真实行情原始载荷。 ## 脱敏与限制 - 报告不保存真实价格、成交量、家庭网络地址、Tailscale 地址、SSH 用户、密钥或完整响应。 -- 本报告只证明当前开发机到 TDX 的只读协议可达,不证明 7×24 生产采集、商业再分发授权或所有板块/资金方法已经可用。 -- 后续接口实现必须继续以小批量只读请求验证板块、宽度和资金字段,并在映射测试中固定字段口径。 +- 本报告只证明当前开发机到 TDX 的只读接口和本次映射可达,不证明 7×24 生产采集、商业再分发授权、主力净流入口径或盘中新闻接口已经可用。 +- 主力净流入和盘中新闻继续保留为补充数据源需求;后续接入必须单独记录来源、权限、口径和数据质量。 diff --git a/docs/features/FEAT-MO-001-market-overview/test-plan.md b/docs/features/FEAT-MO-001-market-overview/test-plan.md index f229f33..61269ce 100644 --- a/docs/features/FEAT-MO-001-market-overview/test-plan.md +++ b/docs/features/FEAT-MO-001-market-overview/test-plan.md @@ -4,7 +4,7 @@ - 分析状态可以临时切换并保存默认值。 - 依据抽屉能够打开和关闭。 - 五个模块并行请求,一个模块失败不阻断其他模块。 -- 真实 TDX 指数和摘要响应可映射,未提供的方法返回 `unavailable`,不伪造行情。 +- 真实 TDX 指数、市场宽度、一级行业板块和成交额趋势响应可映射;主力净流入与盘中新闻不以占位值伪造。 - 加载、部分数据、过期和错误状态具有明确文案。 - 桌面和窄窗口不出现阻断核心内容的溢出。 @@ -12,14 +12,14 @@ - 前端 Vitest、typecheck、build:通过。 - 后端 TDX 客户端、映射、服务、HTTP/OpenAPI 测试:通过。 -- `curl` 冒烟:`/summary`、`/indices` 返回 `source=tdx,status=fresh`;`/breadth`、`/sectors`、`/funds` 返回结构化不可用状态。 +- `curl` 冒烟:`/breadth`、`/sectors`、`/funds` 均返回 `source=tdx,status=fresh`;分别验证 `UpHome/DownHome` 汇总、一级行业排序和成交额单位转换。 - 浏览器验证:桌面默认视口和 390px 移动视口无横向溢出;综合/短线切换和分析引擎选择可用。 ## 安全、性能与残余风险 - 安全:TDX 地址固定为本机回环桥接端口;请求超时、响应 1 MiB 上限、错误脱敏;只读行情,不接受用户输入拼接请求。 - 性能:五个模块并行请求,单次 TDX 请求受超时保护;本切片不引入数据库、缓存或消息负载,未进行持续压测。 -- 残余风险:桥接层尚未提供宽度、板块和资金方法;详情页、依据抽屉和 AI 运行时需另立功能;市场数据授权和生产 7×24 稳定性未由本地冒烟证明。 +- 残余风险:主力净流入字段当前桥接实测为零值,需权限和口径专项验证;盘中新闻仍需补充源;详情页、依据抽屉和 AI 运行时需另立功能;市场数据授权和生产 7×24 稳定性未由本地冒烟证明。 ## 安全验证 diff --git a/server/apps/api/src/market-overview/market-overview.dto.ts b/server/apps/api/src/market-overview/market-overview.dto.ts index 3f861b0..7caf1b1 100644 --- a/server/apps/api/src/market-overview/market-overview.dto.ts +++ b/server/apps/api/src/market-overview/market-overview.dto.ts @@ -72,18 +72,44 @@ export class BreadthDataDto { @ApiProperty({ type: Number }) declining!: number; - @ApiProperty({ type: Number }) - unchanged!: number; + @ApiPropertyOptional({ type: Number, nullable: true }) + 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 { - @ApiProperty({ type: [String] }) - items!: string[]; + @ApiProperty({ type: [SectorQuoteDto] }) + items!: SectorQuoteDto[]; } export class FundDataDto { - @ApiProperty({ type: [String] }) - items!: string[]; + @ApiProperty({ type: Number }) + currentAmount!: number; + + @ApiProperty({ type: Number }) + previousAmount!: number; + + @ApiProperty({ type: Number }) + changePercent!: number; + + @ApiProperty({ type: String, example: '亿元' }) + unit!: '亿元'; } export class SummaryResponseDto extends MarketOverviewMetaDto { diff --git a/server/apps/api/src/market-overview/market-overview.service.test.ts b/server/apps/api/src/market-overview/market-overview.service.test.ts index 8d934bf..e376122 100644 --- a/server/apps/api/src/market-overview/market-overview.service.test.ts +++ b/server/apps/api/src/market-overview/market-overview.service.test.ts @@ -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('市场概览服务', () => { it('读取指数并返回统一快照元数据', async () => { const client = { request: vi.fn().mockResolvedValue(indexResult) }; @@ -38,14 +95,69 @@ describe('市场概览服务', () => { ); }); - it('对未验证的市场宽度能力返回不可用而不是伪造数据', async () => { - const client = { request: vi.fn() }; + it('汇总三个市场指数的上涨和下跌家数并保留未知平盘数', async () => { + const client = { + request: vi + .fn() + .mockResolvedValueOnce(breadthSnapshot('10', '5')) + .mockResolvedValueOnce(breadthSnapshot('20', '15')) + .mockResolvedValueOnce(breadthSnapshot('3', '2')), + }; const service = new MarketOverviewService(client); const response = await service.getBreadth(); - expect(response).toMatchObject({ source: 'tdx', status: 'unavailable', data: null }); - expect(response.warnings[0]).toContain('市场宽度'); - expect(client.request).not.toHaveBeenCalled(); + expect(response).toMatchObject({ + source: 'tdx', + 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([]); }); }); diff --git a/server/apps/api/src/market-overview/market-overview.service.ts b/server/apps/api/src/market-overview/market-overview.service.ts index b29e87f..eed3f50 100644 --- a/server/apps/api/src/market-overview/market-overview.service.ts +++ b/server/apps/api/src/market-overview/market-overview.service.ts @@ -8,9 +8,19 @@ import type { SummaryDataDto, } from './market-overview.dto'; 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 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'); @@ -32,10 +42,6 @@ function createSnapshotMeta(): Pick< return { snapshotId, asOf, source: 'tdx' }; } -function unavailable(warning: string): MarketOverviewResponse { - return { ...createSnapshotMeta(), status: 'unavailable', data: null, warnings: [warning] }; -} - @Injectable() export class MarketOverviewService { constructor(@Inject(TDX_CLIENT_PORT) private readonly tdxClient: TdxClient) {} @@ -89,24 +95,110 @@ export class MarketOverviewService { ? '市场维持偏强震荡' : '市场维持偏弱震荡', marketStatus, - volumeStatus: '成交量口径待接入', - sentimentStatus: '市场情绪口径待接入', + volumeStatus: '成交额趋势见下方', + sentimentStatus: '涨跌家数见下方', drivers: changes.length ? [`核心指数平均涨跌幅 ${averageChange}%`] : ['核心指数数据不足'], - risks: ['市场宽度、板块和资金指标尚未由当前 TDX 桥接层提供'], + risks: ['主力净流入和盘中新闻仍待补充数据源验证'], }; return { ...indices, data: summary }; } async getBreadth(): Promise> { - return unavailable('当前 TDX 桥接层未提供市场宽度方法'); + const meta = createSnapshotMeta(); + const results = await Promise.allSettled( + BREADTH_INDEXES.map((item) => + this.tdxClient.request('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> { - return unavailable('当前 TDX 桥接层未提供板块排行方法'); + const meta = createSnapshotMeta(); + try { + const catalog = await this.tdxClient.request('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('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> { - return unavailable('当前 TDX 桥接层未提供资金趋势方法'); + const meta = createSnapshotMeta(); + try { + const result = await this.tdxClient.request('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 { diff --git a/server/apps/api/src/market-overview/tdx/tdx.mapper.test.ts b/server/apps/api/src/market-overview/tdx/tdx.mapper.test.ts index 881b5c4..ef46ae7 100644 --- a/server/apps/api/src/market-overview/tdx/tdx.mapper.test.ts +++ b/server/apps/api/src/market-overview/tdx/tdx.mapper.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { mapIndexQuotes } from './tdx.mapper'; +import { mapBreadthSnapshots, mapIndexQuotes, mapTurnoverTrend } from './tdx.mapper'; describe('TDX 行情映射', () => { it('将 K 线数组转换为 A 股红涨绿跌的指数报价', () => { @@ -55,4 +55,35 @@ describe('TDX 行情映射', () => { 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: '亿元' }); + }); }); diff --git a/server/apps/api/src/market-overview/tdx/tdx.mapper.ts b/server/apps/api/src/market-overview/tdx/tdx.mapper.ts index fb8d4c6..5fa58a8 100644 --- a/server/apps/api/src/market-overview/tdx/tdx.mapper.ts +++ b/server/apps/api/src/market-overview/tdx/tdx.mapper.ts @@ -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'; function toNumber(value: string | undefined): number | null { @@ -46,3 +51,51 @@ export function mapIndexQuotes( 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: '亿元', + }; +} diff --git a/server/apps/api/src/market-overview/tdx/tdx.types.ts b/server/apps/api/src/market-overview/tdx/tdx.types.ts index 4161137..afbc43b 100644 --- a/server/apps/api/src/market-overview/tdx/tdx.types.ts +++ b/server/apps/api/src/market-overview/tdx/tdx.types.ts @@ -14,6 +14,20 @@ export interface TdxMarketDataResult { Value: Record; } +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 { code: string; name: string; @@ -25,3 +39,17 @@ export const CORE_INDEXES: readonly IndexDefinition[] = [ { code: '399001.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: '北证市场' }, +]; diff --git a/server/apps/api/test/market-overview.e2e-spec.ts b/server/apps/api/test/market-overview.e2e-spec.ts index 8af0924..c98d0ed 100644 --- a/server/apps/api/test/market-overview.e2e-spec.ts +++ b/server/apps/api/test/market-overview.e2e-spec.ts @@ -72,13 +72,32 @@ describe('市场概览 HTTP API', () => { 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({ controllers: [MarketOverviewController], - providers: [ - MarketOverviewService, - { provide: TDX_CLIENT_PORT, useValue: { request: vi.fn() } }, - ], + providers: [MarketOverviewService, { provide: TDX_CLIENT_PORT, useValue: client }], }).compile(); app = moduleRef.createNestApplication(); app.setGlobalPrefix('api/v1'); @@ -88,7 +107,13 @@ describe('市场概览 HTTP API', () => { .get('/api/v1/market-overview/sectors') .expect(200); - expect(response.body).toMatchObject({ source: 'tdx', status: 'unavailable', data: null }); - expect(response.body.warnings[0]).toContain('板块排行'); + expect(response.body).toMatchObject({ + 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'); }); }); diff --git a/server/openapi/openapi.json b/server/openapi/openapi.json index 6b2dd02..24274cc 100644 --- a/server/openapi/openapi.json +++ b/server/openapi/openapi.json @@ -5,9 +5,9 @@ "properties": { "advancing": { "type": "number" }, "declining": { "type": "number" }, - "unchanged": { "type": "number" } + "unchanged": { "nullable": true, "type": "number" } }, - "required": ["advancing", "declining", "unchanged"], + "required": ["advancing", "declining"], "type": "object" }, "BreadthResponseDto": { @@ -42,9 +42,12 @@ }, "FundDataDto": { "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" }, "FundsResponseDto": { @@ -130,11 +133,25 @@ }, "SectorDataDto": { "properties": { - "items": { "items": { "type": "string" }, "type": "array" } + "items": { + "items": { "$ref": "#/components/schemas/SectorQuoteDto" }, + "type": "array" + } }, "required": ["items"], "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": { "properties": { "asOf": { "format": "date-time", "type": "string" }, diff --git a/web/src/api/generated/schema.ts b/web/src/api/generated/schema.ts index 67df158..144bcdc 100644 --- a/web/src/api/generated/schema.ts +++ b/web/src/api/generated/schema.ts @@ -107,7 +107,7 @@ export interface components { BreadthDataDto: { advancing: number; declining: number; - unchanged: number; + unchanged?: number | null; }; BreadthResponseDto: { /** Format: date-time */ @@ -126,7 +126,11 @@ export interface components { status: 'healthy' | 'unavailable' | 'disabled'; }; FundDataDto: { - items: string[]; + changePercent: number; + currentAmount: number; + previousAmount: number; + /** @example 亿元 */ + unit: string; }; FundsResponseDto: { /** Format: date-time */ @@ -177,7 +181,15 @@ export interface components { warnings: string[]; }; SectorDataDto: { - items: string[]; + items: components['schemas']['SectorQuoteDto'][]; + }; + SectorQuoteDto: { + changePercent?: number | null; + code: string; + /** @enum {string} */ + direction: 'up' | 'down' | 'flat'; + name: string; + tradeDate: string; }; SectorsResponseDto: { /** Format: date-time */ diff --git a/web/src/styles/index.scss b/web/src/styles/index.scss index 297f5c7..25d93a2 100644 --- a/web/src/styles/index.scss +++ b/web/src/styles/index.scss @@ -472,12 +472,54 @@ textarea { } .sector-list button { + display: inline-flex; + align-items: center; + justify-content: space-between; + min-width: 112px; + gap: 10px; padding: 7px 9px; color: #4c5966; background: #f3f7f7; 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) { .overview-grid-top, .overview-grid-bottom { diff --git a/web/src/views/market-overview/components/FundTrend.vue b/web/src/views/market-overview/components/FundTrend.vue index 654b6d7..8d715f3 100644 --- a/web/src/views/market-overview/components/FundTrend.vue +++ b/web/src/views/market-overview/components/FundTrend.vue @@ -8,16 +8,28 @@ defineProps<{ response: FundsResponse | null; loading: boolean }>();
- 资金观察 -

资金趋势

+ 量能观察 +

成交额趋势

- 数据口径 + TDX · 亿元
正在读取资金趋势…
暂不可用 {{ response.warnings[0] }}
-
暂无资金趋势数据
+
+
+ 三市成交额 + {{ response.data.currentAmount.toLocaleString('zh-CN') }} + {{ response.data.unit }} +
+

+ 较前一交易日 + {{ response.data.changePercent > 0 ? '+' : '' + }}{{ response.data.changePercent.toFixed(2) }}% +

+
+
暂无成交额趋势数据
diff --git a/web/src/views/market-overview/components/MarketBreadth.vue b/web/src/views/market-overview/components/MarketBreadth.vue index e0f0c80..9faab9d 100644 --- a/web/src/views/market-overview/components/MarketBreadth.vue +++ b/web/src/views/market-overview/components/MarketBreadth.vue @@ -26,7 +26,7 @@ defineProps<{ response: BreadthResponse | null; loading: boolean }>(); 下跌{{ response.data.declining }}
- 平盘{{ response.data.unchanged }} + 平盘{{ response.data.unchanged ?? '—' }}
暂无宽度数据
diff --git a/web/src/views/market-overview/components/MarketDataPanels.test.ts b/web/src/views/market-overview/components/MarketDataPanels.test.ts new file mode 100644 index 0000000..d720fa9 --- /dev/null +++ b/web/src/views/market-overview/components/MarketDataPanels.test.ts @@ -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('主力净流入'); + }); +}); diff --git a/web/src/views/market-overview/components/SectorOverview.vue b/web/src/views/market-overview/components/SectorOverview.vue index be27ef2..3cbc747 100644 --- a/web/src/views/market-overview/components/SectorOverview.vue +++ b/web/src/views/market-overview/components/SectorOverview.vue @@ -22,11 +22,20 @@ const emit = defineEmits<{ select: [sector: string] }>();
暂无板块数据