fix(FEAT-MO-001): 修复TDX数据完整性与状态展示

This commit is contained in:
2026-08-18 19:30:34 +08:00
parent ff8ada8316
commit 49f6be9c43
20 changed files with 557 additions and 113 deletions
@@ -27,6 +27,7 @@
- 对未注册桥接方法返回结构化原因,避免前端显示“实时”或空白数据。
- 修复 Vue TypeScript 单文件组件未被 ESLint 正确解析的问题,并为 E2E 测试进程补齐必需的本机 TDX 配置边界。
- 隔离应用外壳单测中的真实 Axios 请求,消除 JSDOM 网络错误噪声,保持页面测试只验证外壳职责。
- 根据代码 Review 修复畸形 TDX 字段被折算为零、缺市场成交额误报三市、交易日错位跨日求和、空行业目录误报 `fresh`、前端隐藏 `partial/delayed` 警告和零变化使用上涨色等缺陷,并补齐对应回归测试。
## 未完成与延期项
@@ -41,9 +42,10 @@
## 验证证据
- GitNexus 已完成最终重新索引;提交前 CLI `detect-changes --scope staged` 映射 11 条概览/E2E 执行流并标记 high。高风险来自本功能修改核心概览服务和测试启动入口,已由定向单测、HTTP/OpenAPI、构建和 E2E 覆盖;未发现额外业务模块调用方。MCP context 在当前 worktree 未初始化,因此以 CLI 索引和静态调用证据补充记录。
- GitNexus 已完成提交前重新索引;CLI `detect-changes --scope staged` 映射 16 条概览执行流并标记 critical。风险来自本功能修改核心概览服务、TDX 映射和前端数据面板,已由定向单测、HTTP/OpenAPI、前端状态回归、构建、浏览器视觉验证和 E2E 覆盖;未发现额外业务模块调用方。当前环境未暴露 GitNexus MCP 变更检测工具,因此使用同版本 CLI 并指定 worktree 绝对路径执行。
- API 冒烟:`/breadth`、`/sectors`、`/funds` 均返回 `source=tdx,status=fresh`;完整结果和耗时见 `tdx-smoke-report.md`。
- 前端单测、typecheck、build、后端 TDX/映射/服务/HTTP/OpenAPI 测试通过。
- `pnpm verify:e2e`:4/4 通过(桌面和移动端各覆盖页面与健康契约)。
- 浏览器桌面与 390px 移动视口验证无横向溢出,视角与引擎切换可用。
- Review 首轮在 `ff8ada8` 发现 6 项重要问题;已在当前修复提交中逐项处置,需二次代码 Review 确认。
- 开发 Schema 按用户确认跳过,沿用 `v0.0.10 captured_empty` 追溯边界,不读取生产库。
@@ -10,10 +10,10 @@ npx gitnexus detect-changes --repo "$PWD" --scope staged
## 结果
- 索引:已完成最终增量更新。
- 暂存文件:38 个文件、87 个符号(包含完整功能档案)。
- 影响执行流:11 条,CLI 风险:high。
- 主要执行流:`GetSummary`、`GetBreadth`、`GetSectors`、`GetFunds` 和 `RunTestApi`。
- 暂存文件:20 个文件、67 个符号;GitNexus 自动改写的 `AGENTS.md` 已明确排除。
- 影响执行流:16 条,CLI 风险:critical。
- 主要执行流:`GetSummary`、`GetIndices`、`GetBreadth`、`GetSectors` 和 `GetFunds`,以及对应映射和状态创建流程。
## 处置
high 来自本功能新增并修改市场概览核心服务、共享快照元数据函数和 E2E 启动入口,不是未知跨业务扩散。已用 TDX 客户端/映射/服务/HTTP/OpenAPI 单测、边界测试、构建和 4/4 E2E 覆盖;未发现用户、消息、数据库或 Scheduler/Worker 业务调用方。MCP context 在当前 worktree 未初始化,未将其缺失误判为低风险。
critical 来自本功能修改市场概览核心服务、TDX 映射和三个前端数据面板,不是未知跨业务扩散。已用 TDX 映射/服务/HTTP/OpenAPI 单测、前端状态回归、边界测试、构建、浏览器视觉验证和 4/4 E2E 覆盖;未发现用户、消息、数据库、Redis、RocketMQ、Scheduler、Worker、认证或部署调用方。当前环境未暴露 GitNexus MCP 变更检测工具,已使用同版本 CLI 并指定 worktree 绝对路径执行,没有将工具限制误判为低风险。
@@ -50,7 +50,7 @@
## 数据与口径
首个切片由 Nest API 直接读取 TDX 只读桥接层。指数和摘要使用真实 TDX K 线映射;当前桥接层未提供市场宽度、板块排行和资金趋势方法,因此这些模块返回结构化 `unavailable`,不得用指数或离线样本冒充。所有响应仍包含交易日期、快照时间、来源状态、完整性和单位。
首个切片由 Nest API 直接读取 TDX 只读桥接层。指数和摘要使用真实 TDX K 线映射;市场宽度使用三市场指数快照,板块使用一级行业目录与日行情,量能使用三市场指数成交额汇总。主力净流入和盘中新闻不在本切片 TDX 主链路中,必须保持明确的补充源边界,不得用指数或离线样本冒充。所有响应仍包含交易日期、快照时间、来源状态、完整性和单位。
A 股使用红涨绿跌。成交额统一展示为亿元,比例明确使用百分比。历史数据和低频资金数据必须显示实际数据日期,旧数据不得标记为实时。
@@ -7,10 +7,13 @@
## 结果
- 通过:`document.documentElement.scrollWidth` 未超过视口宽度。
- 通过:2026-08-18 使用本地 API/Web 实际页面复验;桌面视口为 `1440×900`,移动视口为 `390×844`。
- 通过:桌面 `clientWidth=1440, scrollWidth=1440`,移动端 `clientWidth=390, scrollWidth=390`;移动端六个主要区块左右边界均位于 `14px~376px`,未发生页面级或区块级横向溢出。
- 通过:综合、短线、中长线切换和规则/AI/混合/对照选择有可见状态。
- 通过:TDX 来源、更新时间和不可用原因可见,未把不可用模块渲染为空白。
- 通过:实际切换到“综合”和“混合”后,按钮激活态和下拉选中项同步更新。
- 通过:TDX 来源、更新时间和不可用原因可见;当前 `127.0.0.1:17710` 未监听时,摘要、指数、宽度、板块和成交额趋势均显示结构化不可用状态,没有空白或伪造实时数据。
- 通过:移动端浏览器控制台无 `warning` 或 `error`。
## 残余项
依据抽屉、指数/板块详情页和真实图表尚未立项,不作为本次首屏视觉验收项。
依据抽屉、指数/板块详情页和真实图表尚未立项,不作为本次首屏视觉验收项。真实 TDX 数据展示的接口冒烟证据沿用 `tdx-smoke-report.md`;本次隧道未监听,因此没有覆盖或伪造新的真实行情截图。
@@ -4,10 +4,11 @@
- 新增 TDX 直连的市场概览首屏和五个模块 API。
- 支持综合、短线、中长线视角与规则、AI、混合、对照偏好选择。
- 明确显示 TDX 来源、快照时间、缺失能力和模块级错误。
- 市场宽度、一级行业强弱和成交额趋势接入真实 TDX 数据,并对空数据、部分数据、日期不一致和延迟状态显示明确边界。
- 明确显示 TDX 来源、快照时间、交易日期、市场覆盖数和模块级错误。
## 明确不包含
- 不启用 MySQL、Redis、RocketMQ 或 AI 运行链路。
- 不包含用户体系、详情页、依据抽屉、交易操作和荐股能力。
- 不包含板块、宽度和资金真实数据,直到 TDX 桥接层提供并验证对应方法。
- 不包含主力净流入、盘中新闻、真实图表和详情页;这些能力需另立补充数据源和功能需求。
@@ -1,5 +1,22 @@
# 代码 Review
## 首轮独立 Review
- 对比基线:`8261948`
- 审查提交:`ff8ada8`
- 结论:`Ready to merge: No`
首轮发现及处置:
1. 畸形 `UpHome/DownHome` 被折算为零并误标 `fresh`:改为有限、非负且成对校验,无效市场整体排除并按有效市场数返回 `fresh/partial/unavailable`。
2. 成交额缺市场或交易日错位仍误标三市实时数据:只汇总非负金额和一致交易日,公开 `marketCount`、`tradeDate`、`previousTradeDate`,缺市场返回 `partial`。
3. 空板块目录误标 `fresh`:一级行业为空时直接返回 `unavailable`,且只保留存在相邻两日收盘价和有效涨跌幅的板块。
4. 前端隐藏 `partial/delayed` 警告:宽度、板块和成交额面板均展示非 `fresh` 状态提示,并展示板块与成交额真实交易日期。
5. PRD、发布说明和 Review 文档残留旧口径:已同步更正为真实 TDX 宽度、一级行业与成交额趋势口径,主力净流入和新闻仍明确延期。
6. 异常分支测试不足:新增畸形宽度、部分市场、错位交易日、空行业目录、额外字段隔离、前端状态提示和零变化中性色回归测试。
次要问题也已同步处理:OpenAPI `required + nullable` 定义、板块 DTO 额外 `price` 字段泄露、成交额零变化上涨色和新增映射中文口径注释。
## 范围
覆盖 TDX 客户端、市场概览后端模块、OpenAPI/前端契约、Axios API 封装、Vue 首屏组件和对应测试。
@@ -27,4 +44,8 @@
- 未进行 7×24、断线重连和生产压测;TDX 宽度/板块/资金方法及详情页仍待独立功能。
- ESLint 对 Vue 单文件组件仅保留 35 条既有风格 warning,无 error;格式、类型和构建门禁均通过。
最终结论:passed
## 二次 Review
等待修复提交完成后执行独立二次 Review。
最终结论:pending
@@ -15,4 +15,10 @@
- 开发总结记录原始需求、范围变化、实际完成、修复缺陷、延期项、提交和验证证据。
- QA、发布说明和回退说明与本功能档案保持同一功能编号。
最终结论:passed
## 首轮问题同步检查
- 数据完整性、状态语义、OpenAPI nullable、DTO 输出边界和前端降级提示均已补齐回归测试。
- PRD、发布说明、测试计划和开发总结已同步实际口径,避免实现与文档分叉。
- 浏览器复验覆盖桌面与 `390×844` 移动视口;当前 TDX 隧道未监听时只确认结构化降级,历史真实行情冒烟证据保持独立。
最终结论:pending(等待独立二次代码 Review 与完整门禁)
@@ -13,6 +13,8 @@
- 前端 Vitest、typecheck、build:通过。
- 后端 TDX 客户端、映射、服务、HTTP/OpenAPI 测试:通过。
- `curl` 冒烟:`/breadth`、`/sectors`、`/funds` 均返回 `source=tdx,status=fresh`;分别验证 `UpHome/DownHome` 汇总、一级行业排序和成交额单位转换。
- 异常映射:覆盖畸形涨跌家数字段、空一级行业目录、单根板块 K 线、成交额缺市场/交易日错位/负数,以及三市完整性状态。
- 前端状态:覆盖宽度、板块和成交额模块的 `partial`/`delayed` 警告、真实交易日期和成交额零变化中性色。
- 浏览器验证:桌面默认视口和 390px 移动视口无横向溢出;综合/短线切换和分析引擎选择可用。
## 安全、性能与残余风险
@@ -72,7 +72,7 @@ export class BreadthDataDto {
@ApiProperty({ type: Number })
declining!: number;
@ApiPropertyOptional({ type: Number, nullable: true })
@ApiProperty({ type: Number, nullable: true })
unchanged!: number | null;
}
@@ -83,8 +83,8 @@ export class SectorQuoteDto {
@ApiProperty({ type: String })
name!: string;
@ApiPropertyOptional({ type: Number, nullable: true })
changePercent!: number | null;
@ApiProperty({ type: Number })
changePercent!: number;
@ApiProperty({ enum: ['up', 'down', 'flat'] })
direction!: QuoteDirection;
@@ -110,6 +110,15 @@ export class FundDataDto {
@ApiProperty({ type: String, example: '亿元' })
unit!: '亿元';
@ApiProperty({ type: Number, minimum: 1, maximum: 3 })
marketCount!: number;
@ApiProperty({ type: String, example: '20260818' })
tradeDate!: string;
@ApiProperty({ type: String, example: '20260817' })
previousTradeDate!: string;
}
export class SummaryResponseDto extends MarketOverviewMetaDto {
@@ -118,6 +118,26 @@ describe('市场概览服务', () => {
);
});
it('市场宽度排除畸形快照并标记部分数据', async () => {
const client = {
request: vi
.fn()
.mockResolvedValueOnce(breadthSnapshot('10', '5'))
.mockResolvedValueOnce({ ErrorId: '0', UpHome: '', DownHome: '3' })
.mockRejectedValueOnce(new Error('raw network detail')),
};
const service = new MarketOverviewService(client);
const response = await service.getBreadth();
expect(response).toMatchObject({
status: 'partial',
data: { advancing: 10, declining: 5, unchanged: null },
});
expect(response.warnings[0]).toContain('深证市场、北证市场');
expect(response.warnings.join(' ')).not.toContain('raw network detail');
});
it('按一级行业涨跌幅排序板块并保留 TDX 代码', async () => {
const client = {
request: vi.fn().mockResolvedValueOnce(sectorResult).mockResolvedValueOnce(sectorKlineResult),
@@ -142,6 +162,46 @@ describe('市场概览服务', () => {
});
});
it('一级行业目录为空时返回不可用而不是 fresh 空数据', async () => {
const client = { request: vi.fn().mockResolvedValue({ ErrorId: '0', Value: [] }) };
const service = new MarketOverviewService(client);
const response = await service.getSectors();
expect(response).toMatchObject({ status: 'unavailable', data: null });
expect(response.warnings).toEqual(['一级行业目录为空']);
expect(client.request).toHaveBeenCalledTimes(1);
});
it('一级行业强弱结果最多展示前八项', async () => {
const definitions = Array.from({ length: 9 }, (_, index) => ({
Code: `88100${index}.SH`,
Name: `行业${index}`,
}));
const value = Object.fromEntries(
definitions.map(({ Code }, index) => [
Code,
{
...indexSeries,
Close: ['100', `${101 + index}`],
},
]),
);
const client = {
request: vi
.fn()
.mockResolvedValueOnce({ ErrorId: '0', Value: definitions })
.mockResolvedValueOnce({ ErrorId: '0', Value: value }),
};
const service = new MarketOverviewService(client);
const response = await service.getSectors();
expect(response.status).toBe('fresh');
expect(response.data?.items).toHaveLength(8);
expect(response.data?.items[0].name).toBe('行业8');
});
it('按交易所指数成交额计算亿元级趋势而不宣称主力净流入', async () => {
const client = { request: vi.fn().mockResolvedValue(turnoverResult) };
const service = new MarketOverviewService(client);
@@ -156,8 +216,26 @@ describe('市场概览服务', () => {
previousAmount: 30,
changePercent: 20,
unit: '亿元',
marketCount: 3,
tradeDate: '20260818',
previousTradeDate: '20260817',
},
});
expect(response.warnings).toEqual([]);
});
it('成交额缺少一个市场时标记 partial 并展示实际市场数', async () => {
const partialResult = structuredClone(turnoverResult);
delete partialResult.Value['899050.BJ'];
const client = { request: vi.fn().mockResolvedValue(partialResult) };
const service = new MarketOverviewService(client);
const response = await service.getFunds();
expect(response).toMatchObject({
status: 'partial',
data: { currentAmount: 24, previousAmount: 20, marketCount: 2, tradeDate: '20260818' },
});
expect(response.warnings).toEqual(['部分市场成交额不可用,当前汇总 2 个市场']);
});
});
@@ -112,28 +112,34 @@ export class MarketOverviewService {
}),
),
);
const snapshots = results.flatMap((result) =>
result.status === 'fulfilled' ? [result.value] : [],
);
if (!snapshots.length) {
const validSnapshots = results.flatMap((result) => {
if (result.status === 'rejected') return [];
return mapBreadthSnapshots([result.value]).validCount === 1 ? [result.value] : [];
});
const mapped = mapBreadthSnapshots(validSnapshots);
const unavailableMarkets = BREADTH_INDEXES.filter((_, index) => {
const result = results[index];
return (
!result ||
result.status === 'rejected' ||
mapBreadthSnapshots([result.value]).validCount === 0
);
}).map((item) => item.name);
if (!mapped.data) {
return {
...meta,
status: 'unavailable',
data: null,
warnings: [
this.toWarning(
results[0]?.status === 'rejected' ? results[0].reason : undefined,
'市场宽度',
),
],
warnings: [`市场宽度数据不可用:${unavailableMarkets.join('、')}`],
};
}
const status = snapshots.length === BREADTH_INDEXES.length ? 'fresh' : 'partial';
const status = mapped.validCount === BREADTH_INDEXES.length ? 'fresh' : 'partial';
return {
...meta,
status,
data: mapBreadthSnapshots(snapshots),
warnings: status === 'partial' ? ['部分市场指数未返回涨跌家数'] : [],
data: mapped.data,
warnings:
status === 'partial' ? [`部分市场涨跌家数不可用:${unavailableMarkets.join('、')}`] : [],
};
}
@@ -145,6 +151,14 @@ export class MarketOverviewService {
list_type: 1,
});
const definitions = catalog.Value.map(({ Code: code, Name: name }) => ({ code, name }));
if (!definitions.length) {
return {
...meta,
status: 'unavailable',
data: null,
warnings: ['一级行业目录为空'],
};
}
const result = await this.tdxClient.request<TdxMarketDataResult>('get_market_data', {
stock_list: definitions.map((item) => item.code),
count: 2,
@@ -187,10 +201,19 @@ export class MarketOverviewService {
dividend_type: 'none',
period: '1d',
});
const data = mapTurnoverTrend(result, TURNOVER_INDEXES);
if (!data)
const mapped = mapTurnoverTrend(result, TURNOVER_INDEXES);
if (!mapped.data)
return { ...meta, status: 'unavailable', data: null, warnings: ['成交额趋势数据不完整'] };
return { ...meta, status: 'fresh', data, warnings: [] };
const status = mapped.validMarketCount === TURNOVER_INDEXES.length ? 'fresh' : 'partial';
return {
...meta,
status,
data: mapped.data,
warnings:
status === 'partial'
? [`部分市场成交额不可用,当前汇总 ${mapped.validMarketCount} 个市场`]
: [],
};
} catch (error) {
return {
...meta,
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest';
import { mapBreadthSnapshots, mapIndexQuotes, mapTurnoverTrend } from './tdx.mapper';
import {
mapBreadthSnapshots,
mapIndexQuotes,
mapSectorQuotes,
mapTurnoverTrend,
} from './tdx.mapper';
describe('TDX 行情映射', () => {
it('将 K 线数组转换为 A 股红涨绿跌的指数报价', () => {
@@ -62,7 +67,66 @@ describe('TDX 行情映射', () => {
{ UpHome: '10', DownHome: '5' },
{ UpHome: '2', DownHome: '3' },
]),
).toEqual({ advancing: 12, declining: 8, unchanged: null });
).toEqual({
data: { advancing: 12, declining: 8, unchanged: null },
validCount: 2,
});
});
it('市场宽度排除字段缺失、非数值和负数的快照', () => {
expect(
mapBreadthSnapshots([
{ UpHome: '10', DownHome: '5' },
{ UpHome: '', DownHome: '3' },
{ UpHome: 'NaN', DownHome: '2' },
{ UpHome: '-1', DownHome: '2' },
]),
).toEqual({
data: { advancing: 10, declining: 5, unchanged: null },
validCount: 1,
});
});
it('板块只保留有两个交易日和有效涨跌幅的精确字段', () => {
const result = mapSectorQuotes(
{
ErrorId: '0',
Value: {
'881001.SH': {
Date: ['20260817', '20260818'],
Open: ['1', '1'],
High: ['1', '1'],
Low: ['1', '1'],
Close: ['100', '102'],
Amount: ['1', '1'],
Volume: ['1', '1'],
},
'881002.SH': {
Date: ['20260818'],
Open: ['1'],
High: ['1'],
Low: ['1'],
Close: ['100'],
Amount: ['1'],
Volume: ['1'],
},
},
},
[
{ code: '881001.SH', name: '行业一' },
{ code: '881002.SH', name: '行业二' },
],
);
expect(result).toEqual([
{
code: '881001.SH',
name: '行业一',
changePercent: 2,
direction: 'up',
tradeDate: '20260818',
},
]);
});
it('成交额映射将 TDX 万元转换为亿元并计算环比', () => {
@@ -84,6 +148,49 @@ describe('TDX 行情映射', () => {
},
[{ code: '000001.SH', name: '上证市场' }],
),
).toEqual({ currentAmount: 12, previousAmount: 10, changePercent: 20, unit: '亿元' });
).toEqual({
data: {
currentAmount: 12,
previousAmount: 10,
changePercent: 20,
unit: '亿元',
marketCount: 1,
tradeDate: '20260818',
previousTradeDate: '20260817',
},
validMarketCount: 1,
});
});
it('成交额只汇总交易日期一致且金额非负的市场', () => {
const series = (dates: string[], amounts: string[]) => ({
Date: dates,
Open: ['1', '1'],
High: ['1', '1'],
Low: ['1', '1'],
Close: ['1', '1'],
Amount: amounts,
Volume: ['1', '1'],
});
const result = mapTurnoverTrend(
{
ErrorId: '0',
Value: {
'000001.SH': series(['20260817', '20260818'], ['100000', '120000']),
'399001.SZ': series(['20260816', '20260817'], ['200000', '240000']),
'899050.BJ': series(['20260817', '20260818'], ['10000', '-1']),
},
},
[
{ code: '000001.SH', name: '上证市场' },
{ code: '399001.SZ', name: '深证市场' },
{ code: '899050.BJ', name: '北证市场' },
],
);
expect(result).toMatchObject({
validMarketCount: 1,
data: { currentAmount: 12, previousAmount: 10, marketCount: 1, tradeDate: '20260818' },
});
});
});
@@ -12,6 +12,11 @@ function toNumber(value: string | undefined): number | null {
return Number.isFinite(parsed) ? parsed : null;
}
function toNonNegativeNumber(value: string | undefined): number | null {
const parsed = toNumber(value);
return parsed !== null && parsed >= 0 ? parsed : null;
}
function mapDirection(changePercent: number | null): IndexQuoteDto['direction'] {
if (changePercent === null || changePercent === 0) return 'flat';
return changePercent > 0 ? 'up' : 'down';
@@ -52,50 +57,98 @@ export function mapIndexQuotes(
});
}
export function mapBreadthSnapshots(
snapshots: readonly { UpHome?: string; DownHome?: string }[],
): BreadthDataDto {
const advancing = snapshots.reduce((total, item) => total + (toNumber(item.UpHome) ?? 0), 0);
const declining = snapshots.reduce((total, item) => total + (toNumber(item.DownHome) ?? 0), 0);
return { advancing, declining, unchanged: null };
/** TDX 涨跌家数必须成对有效且非负;无效市场整体排除,禁止把缺失字段折算为零。 */
export function mapBreadthSnapshots(snapshots: readonly { UpHome?: string; DownHome?: string }[]): {
data: BreadthDataDto | null;
validCount: number;
} {
const validSnapshots = snapshots.flatMap((item) => {
const advancing = toNonNegativeNumber(item.UpHome);
const declining = toNonNegativeNumber(item.DownHome);
return advancing === null || declining === null ? [] : [{ advancing, declining }];
});
if (!validSnapshots.length) return { data: null, validCount: 0 };
return {
data: {
advancing: validSnapshots.reduce((total, item) => total + item.advancing, 0),
declining: validSnapshots.reduce((total, item) => total + item.declining, 0),
unchanged: null,
},
validCount: validSnapshots.length,
};
}
/** 一级行业只有在具备相邻两日收盘价时才参与强弱排序,返回对象严格匹配公开 DTO。 */
export function mapSectorQuotes(
result: TdxMarketDataResult,
definitions: readonly IndexDefinition[],
): SectorQuoteDto[] {
return mapIndexQuotes(result, definitions).sort((left, right) => {
return (
(right.changePercent ?? Number.NEGATIVE_INFINITY) -
(left.changePercent ?? Number.NEGATIVE_INFINITY)
);
});
return definitions
.flatMap((definition) => {
const series = result.Value[definition.code];
if (!series) return [];
const quote = mapIndexQuote(series, definition);
if (!quote || quote.changePercent === null) return [];
return [
{
code: quote.code,
name: quote.name,
changePercent: quote.changePercent,
direction: quote.direction,
tradeDate: quote.tradeDate,
},
];
})
.sort((left, right) => right.changePercent - left.changePercent);
}
/** 成交额仅汇总交易日期对齐、金额非负的市场;TDX Amount 从万元转换为亿元。 */
export function mapTurnoverTrend(
result: TdxMarketDataResult,
definitions: readonly IndexDefinition[],
): FundDataDto | null {
let currentAmount = 0;
let previousAmount = 0;
let validSeries = 0;
for (const definition of definitions) {
): { data: FundDataDto | null; validMarketCount: number } {
const candidates = definitions.flatMap((definition) => {
const series = result.Value[definition.code];
if (!series) continue;
const current = toNumber(series.Amount.at(-1));
const previous = toNumber(series.Amount.at(-2));
if (current === null || previous === null) continue;
currentAmount += current;
previousAmount += previous;
validSeries += 1;
}
if (!series) return [];
const current = toNonNegativeNumber(series.Amount.at(-1));
const previous = toNonNegativeNumber(series.Amount.at(-2));
const tradeDate = series.Date.at(-1);
const previousTradeDate = series.Date.at(-2);
if (
current === null ||
previous === null ||
previous === 0 ||
!tradeDate ||
!previousTradeDate ||
tradeDate === previousTradeDate
) {
return [];
}
return [{ current, previous, tradeDate, previousTradeDate }];
});
if (!candidates.length) return { data: null, validMarketCount: 0 };
const expected = candidates[0];
const aligned = candidates.filter(
(item) =>
item.tradeDate === expected.tradeDate &&
item.previousTradeDate === expected.previousTradeDate,
);
const currentAmount = aligned.reduce((total, item) => total + item.current, 0);
const previousAmount = aligned.reduce((total, item) => total + item.previous, 0);
if (previousAmount === 0) return { data: null, validMarketCount: aligned.length };
if (!validSeries || previousAmount === 0) return null;
return {
currentAmount: Math.round((currentAmount / 10000) * 100) / 100,
previousAmount: Math.round((previousAmount / 10000) * 100) / 100,
changePercent: Math.round(((currentAmount - previousAmount) / previousAmount) * 10000) / 100,
unit: '亿元',
data: {
currentAmount: Math.round((currentAmount / 10000) * 100) / 100,
previousAmount: Math.round((previousAmount / 10000) * 100) / 100,
changePercent: Math.round(((currentAmount - previousAmount) / previousAmount) * 10000) / 100,
unit: '亿元',
marketCount: aligned.length,
tradeDate: expected.tradeDate,
previousTradeDate: expected.previousTradeDate,
},
validMarketCount: aligned.length,
};
}
+15 -4
View File
@@ -7,7 +7,7 @@
"declining": { "type": "number" },
"unchanged": { "nullable": true, "type": "number" }
},
"required": ["advancing", "declining"],
"required": ["advancing", "declining", "unchanged"],
"type": "object"
},
"BreadthResponseDto": {
@@ -44,10 +44,21 @@
"properties": {
"changePercent": { "type": "number" },
"currentAmount": { "type": "number" },
"marketCount": { "maximum": 3, "minimum": 1, "type": "number" },
"previousAmount": { "type": "number" },
"previousTradeDate": { "example": "20260817", "type": "string" },
"tradeDate": { "example": "20260818", "type": "string" },
"unit": { "example": "亿元", "type": "string" }
},
"required": ["currentAmount", "previousAmount", "changePercent", "unit"],
"required": [
"currentAmount",
"previousAmount",
"changePercent",
"unit",
"marketCount",
"tradeDate",
"previousTradeDate"
],
"type": "object"
},
"FundsResponseDto": {
@@ -143,13 +154,13 @@
},
"SectorQuoteDto": {
"properties": {
"changePercent": { "nullable": true, "type": "number" },
"changePercent": { "type": "number" },
"code": { "type": "string" },
"direction": { "enum": ["up", "down", "flat"], "type": "string" },
"name": { "type": "string" },
"tradeDate": { "type": "string" }
},
"required": ["code", "name", "direction", "tradeDate"],
"required": ["code", "name", "changePercent", "direction", "tradeDate"],
"type": "object"
},
"SectorsResponseDto": {
+7 -2
View File
@@ -107,7 +107,7 @@ export interface components {
BreadthDataDto: {
advancing: number;
declining: number;
unchanged?: number | null;
unchanged: number | null;
};
BreadthResponseDto: {
/** Format: date-time */
@@ -128,7 +128,12 @@ export interface components {
FundDataDto: {
changePercent: number;
currentAmount: number;
marketCount: number;
previousAmount: number;
/** @example 20260817 */
previousTradeDate: string;
/** @example 20260818 */
tradeDate: string;
/** @example 亿元 */
unit: string;
};
@@ -184,7 +189,7 @@ export interface components {
items: components['schemas']['SectorQuoteDto'][];
};
SectorQuoteDto: {
changePercent?: number | null;
changePercent: number;
code: string;
/** @enum {string} */
direction: 'up' | 'down' | 'flat';
+16
View File
@@ -297,6 +297,18 @@ textarea {
font-size: 12px;
}
.overview-panel .module-warning {
display: flex;
gap: 8px;
padding: 7px 9px;
border-left: 3px solid #b67a2d;
background: #fff8ec;
}
.overview-panel .module-warning strong {
white-space: nowrap;
}
.overview-grid {
display: grid;
gap: 16px;
@@ -497,6 +509,10 @@ textarea {
color: #19846e;
}
.turnover-trend [data-direction='flat'] {
color: #6f7a84;
}
.turnover-trend {
display: grid;
gap: 12px;
@@ -2,6 +2,10 @@
import type { FundsResponse } from '@/types/market-overview';
defineProps<{ response: FundsResponse | null; loading: boolean }>();
function formatTradeDate(value: string | undefined): string {
return value?.length === 8 ? `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6)}` : '—';
}
</script>
<template>
@@ -11,25 +15,39 @@ defineProps<{ response: FundsResponse | null; loading: boolean }>();
<span class="section-kicker">量能观察</span>
<h2 id="funds-title">成交额趋势</h2>
</div>
<span class="panel-meta">TDX · 亿元</span>
<span class="panel-meta">{{ formatTradeDate(response?.data?.tradeDate) }}</span>
</div>
<div v-if="loading" class="module-placeholder">正在读取资金趋势…</div>
<div v-else-if="response?.status === 'unavailable'" class="module-unavailable">
<strong>暂不可用</strong>
<span>{{ response.warnings[0] }}</span>
</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>
<template v-else-if="response?.data">
<div v-if="response.status !== 'fresh'" class="module-warning">
<strong>{{ response.status === 'partial' ? '部分数据' : '数据延迟' }}</strong>
<span>{{ response.warnings[0] }}</span>
</div>
<p :data-direction="response.data.changePercent >= 0 ? 'up' : 'down'">
较前一交易日
{{ response.data.changePercent > 0 ? '+' : ''
}}{{ response.data.changePercent.toFixed(2) }}%
</p>
</div>
<div class="turnover-trend">
<div>
<span>{{ response.data.marketCount }} 市成交额</span>
<strong>{{ response.data.currentAmount.toLocaleString('zh-CN') }}</strong>
<small>{{ response.data.unit }}</small>
</div>
<p
:data-direction="
response.data.changePercent === 0
? 'flat'
: response.data.changePercent > 0
? 'up'
: 'down'
"
>
较前一交易日
{{ response.data.changePercent > 0 ? '+' : ''
}}{{ response.data.changePercent.toFixed(2) }}%
</p>
</div>
</template>
<div v-else class="module-placeholder">暂无成交额趋势数据</div>
</section>
</template>
@@ -18,17 +18,23 @@ defineProps<{ response: BreadthResponse | null; loading: boolean }>();
<strong>暂不可用</strong>
<span>{{ response.warnings[0] }}</span>
</div>
<div v-else-if="response?.data" class="breadth-values">
<div>
<span>上涨</span><strong class="positive">{{ response.data.advancing }}</strong>
<template v-else-if="response?.data">
<div v-if="response.status !== 'fresh'" class="module-warning">
<strong>{{ response.status === 'partial' ? '部分数据' : '数据延迟' }}</strong>
<span>{{ response.warnings[0] }}</span>
</div>
<div>
<span>下跌</span><strong class="negative">{{ response.data.declining }}</strong>
<div class="breadth-values">
<div>
<span>上涨</span><strong class="positive">{{ response.data.advancing }}</strong>
</div>
<div>
<span>下跌</span><strong class="negative">{{ response.data.declining }}</strong>
</div>
<div>
<span>平盘</span><strong>{{ response.data.unchanged ?? '—' }}</strong>
</div>
</div>
<div>
<span>平盘</span><strong>{{ response.data.unchanged ?? '—' }}</strong>
</div>
</div>
</template>
<div v-else class="module-placeholder">暂无宽度数据</div>
</section>
</template>
@@ -54,6 +54,52 @@ describe('市场概览真实数据面板', () => {
expect(wrapper.emitted('select')).toEqual([['881319.SH']]);
});
it('市场宽度 partial 状态显示警告且保留有效数据', () => {
const wrapper = mount(MarketBreadth, {
props: {
loading: false,
response: {
...meta,
status: 'partial',
warnings: ['部分市场涨跌家数不可用:北证市场'],
data: { advancing: 10, declining: 5, unchanged: null },
},
},
});
expect(wrapper.text()).toContain('部分数据');
expect(wrapper.text()).toContain('北证市场');
expect(wrapper.text()).toContain('10');
});
it('板块 delayed 状态显示警告和真实交易日期', () => {
const wrapper = mount(SectorOverview, {
props: {
loading: false,
response: {
...meta,
status: 'delayed',
warnings: ['板块数据已延迟'],
data: {
items: [
{
code: '881319.SH',
name: '半导体',
changePercent: 2.56,
direction: 'up',
tradeDate: '20260818',
},
],
},
},
},
});
expect(wrapper.text()).toContain('数据延迟');
expect(wrapper.text()).toContain('板块数据已延迟');
expect(wrapper.text()).toContain('2026-08-18');
});
it('资金模块明确展示成交额和与上一交易日的变化', () => {
const wrapper = mount(FundTrend, {
props: {
@@ -65,6 +111,9 @@ describe('市场概览真实数据面板', () => {
previousAmount: 11000,
changePercent: 12.23,
unit: '亿元' as const,
marketCount: 3,
tradeDate: '20260818',
previousTradeDate: '20260817',
},
},
},
@@ -75,4 +124,32 @@ describe('市场概览真实数据面板', () => {
expect(wrapper.text()).toContain('较前一交易日 +12.23%');
expect(wrapper.text()).not.toContain('主力净流入');
});
it('部分数据保留数值并显示警告和实际交易日期', () => {
const wrapper = mount(FundTrend, {
props: {
loading: false,
response: {
...meta,
status: 'partial',
warnings: ['部分市场成交额不可用,当前汇总 2 个市场'],
data: {
currentAmount: 100,
previousAmount: 100,
changePercent: 0,
unit: '亿元' as const,
marketCount: 2,
tradeDate: '20260818',
previousTradeDate: '20260817',
},
},
},
});
expect(wrapper.text()).toContain('部分数据');
expect(wrapper.text()).toContain('部分市场成交额不可用');
expect(wrapper.text()).toContain('2 市成交额');
expect(wrapper.text()).toContain('2026-08-18');
expect(wrapper.get('[data-direction="flat"]').attributes('data-direction')).toBe('flat');
});
});
@@ -3,6 +3,10 @@ import type { SectorsResponse } from '@/types/market-overview';
defineProps<{ response: SectorsResponse | null; loading: boolean }>();
const emit = defineEmits<{ select: [sector: string] }>();
function formatTradeDate(value: string | undefined): string {
return value?.length === 8 ? `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6)}` : '—';
}
</script>
<template>
@@ -12,32 +16,34 @@ const emit = defineEmits<{ select: [sector: string] }>();
<span class="section-kicker">板块扫描</span>
<h2 id="sectors-title">强弱分布</h2>
</div>
<span class="panel-meta">点击查看详情</span>
<span class="panel-meta">{{ formatTradeDate(response?.data?.items[0]?.tradeDate) }}</span>
</div>
<div v-if="loading" class="module-placeholder">正在读取板块…</div>
<div v-else-if="response?.status === 'unavailable'" class="module-unavailable">
<strong>暂不可用</strong>
<span>{{ response.warnings[0] }}</span>
</div>
<div v-else-if="response?.data?.items.length" class="sector-list">
<button
v-for="sector in response.data.items"
:key="sector.code"
type="button"
:data-direction="sector.direction"
:title="`进入${sector.name}详细分析`"
@click="emit('select', sector.code)"
>
<span>{{ sector.name }}</span>
<strong>
{{
sector.changePercent == null
? '—'
: `${sector.changePercent > 0 ? '+' : ''}${sector.changePercent.toFixed(2)}%`
}}
</strong>
</button>
</div>
<template v-else-if="response?.data?.items.length">
<div v-if="response.status !== 'fresh'" class="module-warning">
<strong>{{ response.status === 'partial' ? '部分数据' : '数据延迟' }}</strong>
<span>{{ response.warnings[0] }}</span>
</div>
<div class="sector-list">
<button
v-for="sector in response.data.items"
:key="sector.code"
type="button"
:data-direction="sector.direction"
:title="`进入${sector.name}详细分析`"
@click="emit('select', sector.code)"
>
<span>{{ sector.name }}</span>
<strong>{{
`${sector.changePercent > 0 ? '+' : ''}${sector.changePercent.toFixed(2)}%`
}}</strong>
</button>
</div>
</template>
<div v-else class="module-placeholder">暂无板块数据</div>
</section>
</template>