feat(FEAT-MO-004): 实现指数板块详情分析页

This commit is contained in:
2026-08-25 00:16:43 +08:00
parent f8f4da93dc
commit 0131f863b6
32 changed files with 2262 additions and 8 deletions
@@ -0,0 +1,80 @@
import { BadRequestException, Controller, Get, Inject, Param, Query } from '@nestjs/common';
import { ApiOkResponse, ApiParam, ApiQuery, ApiTags } from '@nestjs/swagger';
import {
IndexDetailResponseDto,
SectorDetailResponseDto,
type DetailPeriod,
} from './market-detail.dto';
import { MarketDetailService } from './market-detail.service';
type DetailQuery = { period?: string; count?: string };
function parseDetailQuery(query: DetailQuery): { period: DetailPeriod; count: number } {
const period = query.period ?? '1d';
if (period !== '1d') throw new BadRequestException('只支持 1d 周期');
const count = query.count === undefined ? 120 : Number(query.count);
if (
!/^\d+$/.test(query.count ?? '120') ||
!Number.isSafeInteger(count) ||
count < 1 ||
count > 120
) {
throw new BadRequestException('count 必须是 1 到 120 的整数');
}
return { period, count };
}
function assertCodeShape(code: string): void {
if (!/^\d{6}\.(SH|SZ|BJ)$/.test(code)) {
throw new BadRequestException('代码格式不可用');
}
}
@ApiTags('market-overview-detail')
@Controller('market-overview')
export class MarketDetailController {
constructor(@Inject(MarketDetailService) private readonly service: MarketDetailService) {}
@Get('indices/:code/detail')
@ApiParam({ name: 'code', description: '带市场后缀的指数代码', example: '000001.SH' })
@ApiQuery({ name: 'period', required: false, enum: ['1d'], example: '1d' })
@ApiQuery({
name: 'count',
required: false,
type: Number,
minimum: 1,
maximum: 120,
example: 120,
})
@ApiOkResponse({ type: IndexDetailResponseDto })
getIndexDetail(
@Param('code') code: string,
@Query() query: DetailQuery,
): Promise<IndexDetailResponseDto> {
assertCodeShape(code);
const parsed = parseDetailQuery(query);
return this.service.getIndexDetail(code, parsed.period, parsed.count);
}
@Get('sectors/:code/detail')
@ApiParam({ name: 'code', description: '带市场后缀的一级行业代码', example: '881001.SH' })
@ApiQuery({ name: 'period', required: false, enum: ['1d'], example: '1d' })
@ApiQuery({
name: 'count',
required: false,
type: Number,
minimum: 1,
maximum: 120,
example: 120,
})
@ApiOkResponse({ type: SectorDetailResponseDto })
getSectorDetail(
@Param('code') code: string,
@Query() query: DetailQuery,
): Promise<SectorDetailResponseDto> {
assertCodeShape(code);
const parsed = parseDetailQuery(query);
return this.service.getSectorDetail(code, parsed.period, parsed.count);
}
}
@@ -0,0 +1,100 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { MarketOverviewMetaDto, type QuoteDirection } from '../market-overview.dto';
export type DetailInstrumentType = 'index' | 'sector';
export type DetailPeriod = '1d';
export class DetailInstrumentDto {
@ApiProperty({ type: String })
code!: string;
@ApiProperty({ type: String })
name!: string;
@ApiProperty({ enum: ['index', 'sector'] })
type!: DetailInstrumentType;
}
export class DetailQuoteDto {
@ApiProperty({ type: Number })
price!: number;
@ApiPropertyOptional({ type: Number, nullable: true })
changePercent!: number | null;
@ApiProperty({ enum: ['up', 'down', 'flat'] })
direction!: QuoteDirection;
@ApiProperty({ type: String, example: '20260818' })
tradeDate!: string;
}
export class DetailSeriesPointDto {
@ApiProperty({ type: String, example: '20260818' })
time!: string;
@ApiProperty({ type: Number })
open!: number;
@ApiProperty({ type: Number })
high!: number;
@ApiProperty({ type: Number })
low!: number;
@ApiProperty({ type: Number })
close!: number;
@ApiPropertyOptional({ type: Number, nullable: true })
amount!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true })
volume!: number | null;
}
export class DetailAvailableViewsDto {
@ApiProperty({ type: Boolean })
kline!: boolean;
@ApiProperty({ type: Boolean })
intraday!: boolean;
}
export class IndexDetailDataDto {
@ApiProperty({ type: () => DetailInstrumentDto })
instrument!: DetailInstrumentDto;
@ApiProperty({ type: () => DetailQuoteDto })
quote!: DetailQuoteDto;
@ApiProperty({ type: [DetailSeriesPointDto] })
series!: DetailSeriesPointDto[];
@ApiProperty({ type: () => DetailAvailableViewsDto })
availableViews!: DetailAvailableViewsDto;
}
export class SectorDetailDataDto {
@ApiProperty({ type: () => DetailInstrumentDto })
instrument!: DetailInstrumentDto;
@ApiProperty({ type: () => DetailQuoteDto })
quote!: DetailQuoteDto;
@ApiProperty({ type: [DetailSeriesPointDto] })
series!: DetailSeriesPointDto[];
@ApiProperty({ type: () => DetailAvailableViewsDto })
availableViews!: DetailAvailableViewsDto;
}
export class IndexDetailResponseDto extends MarketOverviewMetaDto {
@ApiProperty({ type: () => IndexDetailDataDto, nullable: true })
data!: IndexDetailDataDto | null;
}
export class SectorDetailResponseDto extends MarketOverviewMetaDto {
@ApiProperty({ type: () => SectorDetailDataDto, nullable: true })
data!: SectorDetailDataDto | null;
}
@@ -0,0 +1,129 @@
import { describe, expect, it } from 'vitest';
import type { TdxKlineSeries } from '../tdx/tdx.types';
import { mapDetailQuote, mapDetailSeries } from './market-detail.mapper';
function createSeries(overrides: Partial<TdxKlineSeries> = {}): TdxKlineSeries {
return {
Date: ['20260817', '20260818'],
Open: ['100', '102'],
High: ['103', '106'],
Low: ['99', '101'],
Close: ['102', '105'],
Amount: ['1000', '1200'],
Volume: ['100', '120'],
...overrides,
};
}
describe('市场详情 K 线映射', () => {
it('按交易日期升序返回有效点并保留可空成交字段', () => {
const series = createSeries({
Date: ['20260818', '20260817', 'bad-date'],
Open: ['102', '100', '1'],
High: ['106', '103', '1'],
Low: ['101', '99', '1'],
Close: ['105', '102', '1'],
Amount: ['', '1000', '1'],
Volume: ['120', '100', '1'],
});
expect(mapDetailSeries(series, 120)).toEqual([
{
time: '20260817',
open: 100,
high: 103,
low: 99,
close: 102,
amount: 1000,
volume: 100,
},
{
time: '20260818',
open: 102,
high: 106,
low: 101,
close: 105,
amount: null,
volume: 120,
},
]);
});
it('过滤价格字段缺失或高低价不自洽的行', () => {
const series = createSeries({
Date: ['20260816', '20260817', '20260818'],
Open: ['100', '', '102'],
High: ['103', '104', '101'],
Low: ['99', '100', '100'],
Close: ['102', '103', '105'],
Amount: ['900', '1000', '1200'],
Volume: ['90', '100', '120'],
});
expect(mapDetailSeries(series, 120)).toEqual([
{
time: '20260816',
open: 100,
high: 103,
low: 99,
close: 102,
amount: 900,
volume: 90,
},
]);
});
it('只保留请求数量内的最近交易日并把数量限制在 120', () => {
const dates = Array.from({ length: 130 }, (_, index) => {
const date = new Date(Date.UTC(2026, 0, index + 1));
return date.toISOString().slice(0, 10).replaceAll('-', '');
});
const values = dates.map((_, index) => String(index + 1));
const series = createSeries({
Date: dates,
Open: values,
High: values,
Low: values,
Close: values,
Amount: values,
Volume: values,
});
const points = mapDetailSeries(series, 999);
expect(points).toHaveLength(120);
expect(points[0]?.time).toBe('20260111');
expect(points.at(-1)?.time).toBe('20260510');
});
it('使用最后两个有效交易日计算最新报价和涨跌幅', () => {
expect(
mapDetailQuote(createSeries(), {
code: '000001.SH',
name: '上证指数',
type: 'index',
}),
).toEqual({
price: 105,
changePercent: 2.94,
direction: 'up',
tradeDate: '20260818',
});
});
it('没有相邻有效收盘价时保留价格但将涨跌幅置空', () => {
expect(
mapDetailQuote(createSeries({ Close: ['0', '105'] }), {
code: '881001.SH',
name: 'A股板块',
type: 'sector',
}),
).toEqual({
price: 105,
changePercent: null,
direction: 'flat',
tradeDate: '20260818',
});
});
});
@@ -0,0 +1,111 @@
import type { QuoteDirection } from '../market-overview.dto';
import type {
DetailInstrumentType,
DetailQuoteDto,
DetailSeriesPointDto,
} from './market-detail.dto';
import type { IndexDefinition, TdxKlineSeries } from '../tdx/tdx.types';
export type DetailInstrumentDefinition = IndexDefinition & { type: DetailInstrumentType };
const MAX_DETAIL_POINTS = 120;
function toNumber(value: string | undefined): number | null {
if (value === undefined || value.trim() === '') return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function toOptionalNonNegativeNumber(value: string | undefined): number | null {
const parsed = toNumber(value);
return parsed !== null && parsed >= 0 ? parsed : null;
}
function toTradeDate(value: string | undefined): string | 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 isValidOhlc(open: number, high: number, low: number, close: number): boolean {
return high >= Math.max(open, close, low) && low <= Math.min(open, close, high);
}
function mapPoint(series: TdxKlineSeries, index: number): DetailSeriesPointDto | null {
const time = toTradeDate(series.Date[index]);
const open = toNumber(series.Open[index]);
const high = toNumber(series.High[index]);
const low = toNumber(series.Low[index]);
const close = toNumber(series.Close[index]);
if (time === null || open === null || high === null || low === null || close === null) {
return null;
}
if (!isValidOhlc(open, high, low, close)) return null;
return {
time,
open,
high,
low,
close,
amount: toOptionalNonNegativeNumber(series.Amount[index]),
volume: toOptionalNonNegativeNumber(series.Volume[index]),
};
}
/** 只映射详情图表需要的字段,过滤坏点并按交易日稳定排序,防止原始数组污染页面。 */
export function mapDetailSeries(series: TdxKlineSeries, count: number): DetailSeriesPointDto[] {
const length = Math.min(
series.Date.length,
series.Open.length,
series.High.length,
series.Low.length,
series.Close.length,
);
const byDate = new Map<string, DetailSeriesPointDto>();
for (let index = 0; index < length; index += 1) {
const point = mapPoint(series, index);
if (point) byDate.set(point.time, point);
}
const safeCount = Number.isSafeInteger(count)
? Math.min(Math.max(count, 1), MAX_DETAIL_POINTS)
: MAX_DETAIL_POINTS;
return [...byDate.values()]
.sort((left, right) => left.time.localeCompare(right.time))
.slice(-safeCount);
}
function mapDirection(changePercent: number | null): QuoteDirection {
if (changePercent === null || changePercent === 0) return 'flat';
return changePercent > 0 ? 'up' : 'down';
}
/** 报价使用最后两个有效交易日;上一收盘为零时不计算涨跌幅,避免无意义的无穷值。 */
export function mapDetailQuote(
series: TdxKlineSeries,
_definition: DetailInstrumentDefinition,
): DetailQuoteDto | null {
const points = mapDetailSeries(series, MAX_DETAIL_POINTS);
const latest = points.at(-1);
if (!latest) return null;
const previous = points.at(-2);
const changePercent =
previous && previous.close > 0
? Math.round(((latest.close - previous.close) / previous.close) * 100 * 100) / 100
: null;
return {
price: latest.close,
changePercent,
direction: mapDirection(changePercent),
tradeDate: latest.time,
};
}
@@ -0,0 +1,134 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TdxClientError } from '../tdx/tdx.client';
import type { TdxClient } from '../tdx/tdx.client';
import { MarketDetailService } from './market-detail.service';
function createKline(overrides: Record<string, string[]> = {}) {
return {
Date: ['20260817', '20260818'],
Open: ['100', '102'],
High: ['103', '106'],
Low: ['99', '101'],
Close: ['102', '105'],
Amount: ['1000', '1200'],
Volume: ['100', '120'],
...overrides,
};
}
describe('市场详情服务', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-08-18T03:00:00.000Z'));
});
it('返回指数详情并限制 TDX 请求为固定周期和数量', async () => {
const client: TdxClient = {
request: vi.fn().mockResolvedValue({
ErrorId: '0',
Value: { '000001.SH': createKline() },
}),
};
const service = new MarketDetailService(client);
const response = await service.getIndexDetail('000001.SH', '1d', 120);
expect(response).toMatchObject({
source: 'tdx',
status: 'fresh',
data: {
instrument: { code: '000001.SH', name: '上证指数', type: 'index' },
quote: { price: 105, changePercent: 2.94, tradeDate: '20260818' },
availableViews: { kline: true, intraday: false },
},
});
expect(client.request).toHaveBeenCalledWith('get_market_data', {
stock_list: ['000001.SH'],
count: 120,
dividend_type: 'none',
period: '1d',
});
});
it('未知指数代码不触发 TDX 请求并返回不可用状态', async () => {
const client: TdxClient = { request: vi.fn() };
const response = await new MarketDetailService(client).getIndexDetail('999999.SH', '1d', 120);
expect(response.status).toBe('unavailable');
expect(response.data).toBeNull();
expect(response.warnings).toEqual(['指数代码不可用']);
expect(client.request).not.toHaveBeenCalled();
});
it('先校验一级行业目录,再读取板块详情', async () => {
const client: TdxClient = {
request: vi
.fn()
.mockResolvedValueOnce({ ErrorId: '0', Value: [{ Code: '881001.SH', Name: '行业总览' }] })
.mockResolvedValueOnce({ ErrorId: '0', Value: { '881001.SH': createKline() } }),
};
const response = await new MarketDetailService(client).getSectorDetail('881001.SH', '1d', 60);
expect(response).toMatchObject({
status: 'fresh',
data: { instrument: { code: '881001.SH', name: '行业总览', type: 'sector' } },
});
expect(client.request).toHaveBeenNthCalledWith(1, 'get_stock_list', {
market: '16',
list_type: 1,
});
expect(client.request).toHaveBeenNthCalledWith(2, 'get_market_data', {
stock_list: ['881001.SH'],
count: 60,
dividend_type: 'none',
period: '1d',
});
});
it('交易日期不是上海当前日期时标记 delayed 并保留数据', async () => {
const client: TdxClient = {
request: vi.fn().mockResolvedValue({
ErrorId: '0',
Value: { '000001.SH': createKline({ Date: ['20260815', '20260817'] }) },
}),
};
const response = await new MarketDetailService(client).getIndexDetail('000001.SH', '1d', 10);
expect(response.status).toBe('delayed');
expect(response.warnings).toEqual(['指数交易日期为 20260817,非当前日期']);
expect(response.data?.series).toHaveLength(2);
});
it('必填字段坏点会保留有效 K 线并标记 partial', async () => {
const client: TdxClient = {
request: vi.fn().mockResolvedValue({
ErrorId: '0',
Value: {
'000001.SH': createKline({ Open: ['100', ''], Close: ['102', '105'] }),
},
}),
};
const response = await new MarketDetailService(client).getIndexDetail('000001.SH', '1d', 120);
expect(response.status).toBe('partial');
expect(response.data?.series).toHaveLength(1);
expect(response.warnings).toEqual(['指数部分 K 线字段不可用']);
});
it('TDX 超时只返回固定脱敏文案', async () => {
const client: TdxClient = {
request: vi.fn().mockRejectedValue(new TdxClientError('timeout', '原始超时细节')),
};
const response = await new MarketDetailService(client).getIndexDetail('000001.SH', '1d', 120);
expect(response.status).toBe('unavailable');
expect(response.data).toBeNull();
expect(response.warnings).toEqual(['指数请求超时']);
expect(JSON.stringify(response)).not.toContain('原始超时细节');
});
});
@@ -0,0 +1,169 @@
import { Inject, Injectable } from '@nestjs/common';
import type { TdxClient } from '../tdx/tdx.client';
import { TdxClientError } from '../tdx/tdx.client';
import {
CORE_INDEXES,
type IndexDefinition,
type TdxKlineSeries,
type TdxMarketDataResult,
type TdxStockListResult,
} from '../tdx/tdx.types';
import { TDX_CLIENT_PORT, type MarketOverviewResponse } from '../market-overview.service';
import type { DetailInstrumentDefinition } from './market-detail.mapper';
import { mapDetailQuote, mapDetailSeries } from './market-detail.mapper';
import type {
DetailPeriod,
IndexDetailDataDto,
IndexDetailResponseDto,
SectorDetailDataDto,
SectorDetailResponseDto,
} from './market-detail.dto';
const MAX_DETAIL_POINTS = 120;
function createSnapshotMeta(): Pick<
MarketOverviewResponse<never>,
'snapshotId' | 'asOf' | 'source'
> {
const asOf = new Date().toISOString();
const snapshotId = `${asOf.slice(0, 10).replaceAll('-', '')}-${asOf.slice(11, 19).replaceAll(':', '')}`;
return { snapshotId, asOf, source: 'tdx' };
}
function getShanghaiCalendarDate(asOf: string): string {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(new Date(asOf));
const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
return `${values.year}${values.month}${values.day}`;
}
function normalizeCount(count: number): number {
return Number.isSafeInteger(count) ? Math.min(Math.max(count, 1), MAX_DETAIL_POINTS) : 120;
}
function createTradeDateWarning(subject: string, tradeDate: string, asOf: string): string | null {
return tradeDate === getShanghaiCalendarDate(asOf)
? null
: `${subject}交易日期为 ${tradeDate},非当前日期`;
}
function getSeries(result: TdxMarketDataResult, code: string): TdxKlineSeries | null {
const series = result.Value?.[code];
return series && typeof series === 'object' ? series : null;
}
@Injectable()
export class MarketDetailService {
constructor(@Inject(TDX_CLIENT_PORT) private readonly tdxClient: TdxClient) {}
async getIndexDetail(
code: string,
period: DetailPeriod,
count: number,
): Promise<IndexDetailResponseDto> {
const definition = CORE_INDEXES.find((item) => item.code === code);
if (!definition || period !== '1d') {
const meta = createSnapshotMeta();
return { ...meta, status: 'unavailable', data: null, warnings: ['指数代码不可用'] };
}
return this.loadDetail({ ...definition, type: 'index' }, period, count, '指数');
}
async getSectorDetail(
code: string,
period: DetailPeriod,
count: number,
): Promise<SectorDetailResponseDto> {
const meta = createSnapshotMeta();
if (period !== '1d')
return { ...meta, status: 'unavailable', data: null, warnings: ['板块查询周期不可用'] };
try {
const catalog = await this.tdxClient.request<TdxStockListResult>('get_stock_list', {
market: '16',
list_type: 1,
});
const definition = catalog.Value?.find((item) => item.Code === code);
if (!definition)
return { ...meta, status: 'unavailable', data: null, warnings: ['板块代码不可用'] };
return this.loadDetail(
{ code: definition.Code, name: definition.Name, type: 'sector' },
period,
count,
'板块',
meta,
);
} catch (error) {
return {
...meta,
status: 'unavailable',
data: null,
warnings: [this.toWarning(error, '板块')],
};
}
}
private async loadDetail<TData extends IndexDetailDataDto | SectorDetailDataDto>(
definition: DetailInstrumentDefinition,
period: DetailPeriod,
count: number,
subject: '指数' | '板块',
inheritedMeta?: Pick<MarketOverviewResponse<never>, 'snapshotId' | 'asOf' | 'source'>,
): Promise<MarketOverviewResponse<TData>> {
const meta = inheritedMeta ?? createSnapshotMeta();
const safeCount = normalizeCount(count);
try {
const result = await this.tdxClient.request<TdxMarketDataResult>('get_market_data', {
stock_list: [definition.code],
count: safeCount,
dividend_type: 'none',
period,
});
const rawSeries = getSeries(result, definition.code);
const series = rawSeries ? mapDetailSeries(rawSeries, safeCount) : [];
const quote = rawSeries ? mapDetailQuote(rawSeries, definition) : null;
if (!rawSeries || !series.length || !quote) {
return { ...meta, status: 'unavailable', data: null, warnings: [`${subject}行情不可用`] };
}
const expectedRows = Math.min(safeCount, rawSeries.Date.length);
const isPartial = series.length < expectedRows;
const tradeDateWarning = createTradeDateWarning(subject, quote.tradeDate, meta.asOf);
const status = isPartial ? 'partial' : tradeDateWarning ? 'delayed' : 'fresh';
const data = {
instrument: { code: definition.code, name: definition.name, type: definition.type },
quote,
series,
// 分时候选协议尚未通过能力探测,禁止在页面伪造该视图。
availableViews: { kline: true, intraday: false },
} as TData;
return {
...meta,
status,
data,
warnings: isPartial
? [`${subject}部分 K 线字段不可用`]
: tradeDateWarning
? [tradeDateWarning]
: [],
};
} catch (error) {
return {
...meta,
status: 'unavailable',
data: null,
warnings: [this.toWarning(error, subject)],
};
}
}
private toWarning(error: unknown, subject: string): string {
if (error instanceof TdxClientError && error.kind === 'timeout') return `${subject}请求超时`;
if (error instanceof TdxClientError && error.kind === 'network') return `${subject}网络不可用`;
return `${subject}数据不可用`;
}
}
@@ -7,6 +7,8 @@ import { ShortTermMarketController } from './short-term.controller';
import { AKTOOLS_CLIENT_PORT, ShortTermMarketService } from './short-term.service';
import { createAktoolsClient } from './aktools/aktools.client';
import { createTdxClient } from './tdx/tdx.client';
import { MarketDetailController } from './detail/market-detail.controller';
import { MarketDetailService } from './detail/market-detail.service';
@Module({})
export class MarketOverviewModule {
@@ -14,9 +16,10 @@ export class MarketOverviewModule {
static register(config: ApiRuntimeConfig): DynamicModule {
return {
module: MarketOverviewModule,
controllers: [MarketOverviewController, ShortTermMarketController],
controllers: [MarketOverviewController, MarketDetailController, ShortTermMarketController],
providers: [
MarketOverviewService,
MarketDetailService,
{ provide: TDX_CLIENT_PORT, useValue: createTdxClient(config.tdx) },
ShortTermMarketService,
{ provide: AKTOOLS_CLIENT_PORT, useValue: createAktoolsClient(config.aktools) },
@@ -0,0 +1,120 @@
import type { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { MarketDetailController } from '../src/market-overview/detail/market-detail.controller';
import { MarketDetailService } from '../src/market-overview/detail/market-detail.service';
import { TDX_CLIENT_PORT } from '../src/market-overview/market-overview.service';
const indexResponse = {
ErrorId: '0',
Value: {
'000001.SH': {
Date: ['20260817', '20260818'],
Open: ['100', '102'],
High: ['103', '106'],
Low: ['99', '101'],
Close: ['102', '105'],
Amount: ['1000', '1200'],
Volume: ['100', '120'],
},
},
};
describe('市场详情 HTTP API', () => {
let app: INestApplication | undefined;
afterEach(async () => {
await app?.close();
app = undefined;
});
it('返回指数详情并隐藏 TDX 原始字段', async () => {
const client = { request: vi.fn().mockResolvedValue(indexResponse) };
const moduleRef = await Test.createTestingModule({
controllers: [MarketDetailController],
providers: [MarketDetailService, { provide: TDX_CLIENT_PORT, useValue: client }],
}).compile();
app = moduleRef.createNestApplication();
app.setGlobalPrefix('api/v1');
await app.init();
const response = await request(app.getHttpServer())
.get('/api/v1/market-overview/indices/000001.SH/detail?count=2')
.expect(200);
expect(response.body).toMatchObject({
source: 'tdx',
data: {
instrument: { code: '000001.SH', name: '上证指数', type: 'index' },
series: [{ time: '20260817' }, { time: '20260818' }],
availableViews: { kline: true, intraday: false },
},
});
expect(JSON.stringify(response.body)).not.toContain('ErrorId');
});
it('拒绝越界数量和非法代码且不调用 TDX', async () => {
const client = { request: vi.fn() };
const moduleRef = await Test.createTestingModule({
controllers: [MarketDetailController],
providers: [MarketDetailService, { provide: TDX_CLIENT_PORT, useValue: client }],
}).compile();
app = moduleRef.createNestApplication();
app.setGlobalPrefix('api/v1');
await app.init();
await request(app.getHttpServer())
.get('/api/v1/market-overview/indices/not-a-code/detail')
.expect(400);
await request(app.getHttpServer())
.get('/api/v1/market-overview/indices/000001.SH/detail?count=121')
.expect(400);
expect(client.request).not.toHaveBeenCalled();
});
it('板块详情先读目录再返回行业 K 线', async () => {
const client = {
request: vi
.fn()
.mockResolvedValueOnce({
ErrorId: '0',
Value: [{ Code: '881001.SH', Name: '行业总览' }],
})
.mockResolvedValueOnce({
ErrorId: '0',
Value: {
'881001.SH': {
Date: ['20260817', '20260818'],
Open: ['100', '102'],
High: ['103', '106'],
Low: ['99', '101'],
Close: ['102', '105'],
Amount: ['1000', '1200'],
Volume: ['100', '120'],
},
},
}),
};
const moduleRef = await Test.createTestingModule({
controllers: [MarketDetailController],
providers: [MarketDetailService, { provide: TDX_CLIENT_PORT, useValue: client }],
}).compile();
app = moduleRef.createNestApplication();
app.setGlobalPrefix('api/v1');
await app.init();
const response = await request(app.getHttpServer())
.get('/api/v1/market-overview/sectors/881001.SH/detail')
.expect(200);
expect(response.body.data.instrument).toEqual({
code: '881001.SH',
name: '行业总览',
type: 'sector',
});
expect(client.request).toHaveBeenCalledTimes(2);
});
});