feat(FEAT-MO-004): 实现指数板块详情分析页
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user