fix(FEAT-MO-001): 校验TDX交易日期语义
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { MarketOverviewService } from './market-overview.service';
|
||||
|
||||
@@ -77,6 +77,15 @@ const turnoverResult = {
|
||||
};
|
||||
|
||||
describe('市场概览服务', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-08-18T03:00:00.000Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('读取指数并返回统一快照元数据', async () => {
|
||||
const client = { request: vi.fn().mockResolvedValue(indexResult) };
|
||||
const service = new MarketOverviewService(client);
|
||||
@@ -96,19 +105,14 @@ describe('市场概览服务', () => {
|
||||
});
|
||||
|
||||
it('核心指数交易日旧于上海当前日期时标记 delayed', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-08-19T03:00:00.000Z'));
|
||||
try {
|
||||
const client = { request: vi.fn().mockResolvedValue(indexResult) };
|
||||
const service = new MarketOverviewService(client);
|
||||
const client = { request: vi.fn().mockResolvedValue(indexResult) };
|
||||
const service = new MarketOverviewService(client);
|
||||
|
||||
const response = await service.getIndices();
|
||||
const response = await service.getIndices();
|
||||
|
||||
expect(response.status).toBe('delayed');
|
||||
expect(response.warnings).toEqual(['核心指数交易日期为 20260818,非当前日期']);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
expect(response.status).toBe('delayed');
|
||||
expect(response.warnings).toEqual(['核心指数交易日期为 20260818,非当前日期']);
|
||||
});
|
||||
|
||||
it('汇总三个市场指数的上涨和下跌家数并保留未知平盘数', async () => {
|
||||
@@ -157,28 +161,22 @@ describe('市场概览服务', () => {
|
||||
});
|
||||
|
||||
it('市场宽度使用核心指数交易日识别 delayed,不把旧快照标为 fresh', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-08-18T03:00:00.000Z'));
|
||||
try {
|
||||
const staleIndexResult = structuredClone(indexResult);
|
||||
staleIndexResult.Value['000001.SH'].Date = ['20260816', '20260817'];
|
||||
const client = {
|
||||
request: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(breadthSnapshot('10', '5'))
|
||||
.mockResolvedValueOnce(breadthSnapshot('20', '15'))
|
||||
.mockResolvedValueOnce(breadthSnapshot('3', '2'))
|
||||
.mockResolvedValueOnce(staleIndexResult),
|
||||
};
|
||||
const service = new MarketOverviewService(client);
|
||||
const staleIndexResult = structuredClone(indexResult);
|
||||
staleIndexResult.Value['000001.SH'].Date = ['20260816', '20260817'];
|
||||
const client = {
|
||||
request: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(breadthSnapshot('10', '5'))
|
||||
.mockResolvedValueOnce(breadthSnapshot('20', '15'))
|
||||
.mockResolvedValueOnce(breadthSnapshot('3', '2'))
|
||||
.mockResolvedValueOnce(staleIndexResult),
|
||||
};
|
||||
const service = new MarketOverviewService(client);
|
||||
|
||||
const response = await service.getBreadth();
|
||||
const response = await service.getBreadth();
|
||||
|
||||
expect(response.status).toBe('delayed');
|
||||
expect(response.warnings).toEqual(['市场宽度交易日期为 20260817,非当前日期']);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
expect(response.status).toBe('delayed');
|
||||
expect(response.warnings).toEqual(['市场宽度交易日期为 20260817,非当前日期']);
|
||||
});
|
||||
|
||||
it('按一级行业涨跌幅排序板块并保留 TDX 代码', async () => {
|
||||
@@ -263,24 +261,16 @@ describe('市场概览服务', () => {
|
||||
});
|
||||
|
||||
it('一级行业交易日旧于上海当前日期时标记 delayed', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-08-19T03:00:00.000Z'));
|
||||
try {
|
||||
const client = {
|
||||
request: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(sectorResult)
|
||||
.mockResolvedValueOnce(sectorKlineResult),
|
||||
};
|
||||
const service = new MarketOverviewService(client);
|
||||
const client = {
|
||||
request: vi.fn().mockResolvedValueOnce(sectorResult).mockResolvedValueOnce(sectorKlineResult),
|
||||
};
|
||||
const service = new MarketOverviewService(client);
|
||||
|
||||
const response = await service.getSectors();
|
||||
const response = await service.getSectors();
|
||||
|
||||
expect(response.status).toBe('delayed');
|
||||
expect(response.warnings).toEqual(['一级行业交易日期为 20260818,非当前日期']);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
expect(response.status).toBe('delayed');
|
||||
expect(response.warnings).toEqual(['一级行业交易日期为 20260818,非当前日期']);
|
||||
});
|
||||
|
||||
it('按交易所指数成交额计算亿元级趋势而不宣称主力净流入', async () => {
|
||||
@@ -321,18 +311,13 @@ describe('市场概览服务', () => {
|
||||
});
|
||||
|
||||
it('成交额交易日旧于上海当前日期时标记 delayed', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-08-19T03:00:00.000Z'));
|
||||
try {
|
||||
const client = { request: vi.fn().mockResolvedValue(turnoverResult) };
|
||||
const service = new MarketOverviewService(client);
|
||||
const client = { request: vi.fn().mockResolvedValue(turnoverResult) };
|
||||
const service = new MarketOverviewService(client);
|
||||
|
||||
const response = await service.getFunds();
|
||||
const response = await service.getFunds();
|
||||
|
||||
expect(response.status).toBe('delayed');
|
||||
expect(response.warnings).toEqual(['成交额交易日期为 20260818,非当前日期']);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
expect(response.status).toBe('delayed');
|
||||
expect(response.warnings).toEqual(['成交额交易日期为 20260818,非当前日期']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -136,6 +136,34 @@ describe('TDX 行情映射', () => {
|
||||
expect(result).toEqual([expect.objectContaining({ code: '399001.SZ', tradeDate: '20260819' })]);
|
||||
});
|
||||
|
||||
it('核心指数拒绝不存在的公历日期,避免畸形日期抢占最新截面', () => {
|
||||
const series = (dates: string[], close: string[]) => ({
|
||||
Date: dates,
|
||||
Open: close,
|
||||
High: close,
|
||||
Low: close,
|
||||
Close: close,
|
||||
Amount: ['1', '1'],
|
||||
Volume: ['1', '1'],
|
||||
});
|
||||
|
||||
expect(
|
||||
mapIndexQuotes(
|
||||
{
|
||||
ErrorId: '0',
|
||||
Value: {
|
||||
'000001.SH': series(['20260817', '20260818'], ['100', '102']),
|
||||
'399001.SZ': series(['20269898', '20269999'], ['100', '101']),
|
||||
},
|
||||
},
|
||||
[
|
||||
{ code: '000001.SH', name: '有效日期指数' },
|
||||
{ code: '399001.SZ', name: '畸形日期指数' },
|
||||
],
|
||||
),
|
||||
).toEqual([expect.objectContaining({ code: '000001.SH', tradeDate: '20260818' })]);
|
||||
});
|
||||
|
||||
it('板块只保留有两个交易日和有效涨跌幅的精确字段', () => {
|
||||
const result = mapSectorQuotes(
|
||||
{
|
||||
@@ -309,4 +337,69 @@ describe('TDX 行情映射', () => {
|
||||
validMarketCount: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('成交额排除倒序和非法公历日期,不能让异常日期抢占汇总锚点', () => {
|
||||
const series = (dates: string[], amounts: string[]) => ({
|
||||
Date: dates,
|
||||
Open: ['1', '1'],
|
||||
High: ['1', '1'],
|
||||
Low: ['1', '1'],
|
||||
Close: ['1', '1'],
|
||||
Amount: amounts,
|
||||
Volume: ['1', '1'],
|
||||
});
|
||||
|
||||
expect(
|
||||
mapTurnoverTrend(
|
||||
{
|
||||
ErrorId: '0',
|
||||
Value: {
|
||||
'000001.SH': series(['20260817', '20260818'], ['100000', '120000']),
|
||||
'399001.SZ': series(['20260817', '20260818'], ['200000', '240000']),
|
||||
'899050.BJ': series(['20260820', '20260819'], ['300000', '360000']),
|
||||
'899051.BJ': series(['20269898', '20269999'], ['300000', '360000']),
|
||||
},
|
||||
},
|
||||
[
|
||||
{ code: '000001.SH', name: '上证市场' },
|
||||
{ code: '399001.SZ', name: '深证市场' },
|
||||
{ code: '899050.BJ', name: '日期倒序市场' },
|
||||
{ code: '899051.BJ', name: '非法日期市场' },
|
||||
],
|
||||
),
|
||||
).toEqual({
|
||||
data: {
|
||||
currentAmount: 36,
|
||||
previousAmount: 30,
|
||||
changePercent: 20,
|
||||
unit: '亿元',
|
||||
marketCount: 2,
|
||||
tradeDate: '20260818',
|
||||
previousTradeDate: '20260817',
|
||||
},
|
||||
validMarketCount: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('成交额拒绝日期与金额数组错位的市场', () => {
|
||||
expect(
|
||||
mapTurnoverTrend(
|
||||
{
|
||||
ErrorId: '0',
|
||||
Value: {
|
||||
'000001.SH': {
|
||||
Date: ['20260816', '20260817', '20260818'],
|
||||
Open: ['1', '1', '1'],
|
||||
High: ['1', '1', '1'],
|
||||
Low: ['1', '1', '1'],
|
||||
Close: ['1', '1', '1'],
|
||||
Amount: ['100000', '120000'],
|
||||
Volume: ['1', '1', '1'],
|
||||
},
|
||||
},
|
||||
},
|
||||
[{ code: '000001.SH', name: '数组错位市场' }],
|
||||
),
|
||||
).toEqual({ data: null, validMarketCount: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,7 +25,21 @@ function toNonNegativeInteger(value: string | undefined): number | null {
|
||||
}
|
||||
|
||||
function toTradeDate(value: string | undefined): string | null {
|
||||
return value && /^\d{8}$/.test(value) ? value : null;
|
||||
if (!value || !/^\d{8}$/.test(value)) return null;
|
||||
|
||||
const year = Number(value.slice(0, 4));
|
||||
const month = Number(value.slice(4, 6));
|
||||
const day = Number(value.slice(6, 8));
|
||||
const date = new Date(0);
|
||||
date.setUTCHours(0, 0, 0, 0);
|
||||
date.setUTCFullYear(year, month - 1, day);
|
||||
|
||||
return year >= 1000 &&
|
||||
date.getUTCFullYear() === year &&
|
||||
date.getUTCMonth() === month - 1 &&
|
||||
date.getUTCDate() === day
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
function latestTradeDate<T extends { tradeDate: string }>(items: readonly T[]): string | null {
|
||||
@@ -133,6 +147,7 @@ export function mapTurnoverTrend(
|
||||
const candidates = definitions.flatMap((definition) => {
|
||||
const series = result.Value[definition.code];
|
||||
if (!series) return [];
|
||||
if (series.Date.length < 2 || series.Date.length !== series.Amount.length) return [];
|
||||
const current = toNonNegativeNumber(series.Amount.at(-1));
|
||||
const previous = toNonNegativeNumber(series.Amount.at(-2));
|
||||
const tradeDate = toTradeDate(series.Date.at(-1));
|
||||
@@ -143,7 +158,7 @@ export function mapTurnoverTrend(
|
||||
previous === 0 ||
|
||||
!tradeDate ||
|
||||
!previousTradeDate ||
|
||||
tradeDate === previousTradeDate
|
||||
previousTradeDate >= tradeDate
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user