fix(FEAT-MO-001): 修复TDX数据完整性与状态展示
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user