feat(FEAT-MO-001): 增加市场概览分模块接口
This commit is contained in:
@@ -6,6 +6,7 @@ import { createMysqlHealthClient } from '../../../libs/infrastructure/src/mysql-
|
||||
import { createRedisHealthClient } from '../../../libs/infrastructure/src/redis-health.client';
|
||||
import { HealthController } from './health.controller';
|
||||
import { HealthService, MYSQL_HEALTH_PORT, REDIS_HEALTH_PORT } from './health.service';
|
||||
import { MarketOverviewModule } from './market-overview/market-overview.module';
|
||||
|
||||
/** API 进程的只读配置注入令牌,确保测试和启动入口使用同一份显式配置。 */
|
||||
export const API_RUNTIME_CONFIG = Symbol('API_RUNTIME_CONFIG');
|
||||
@@ -19,6 +20,7 @@ export class ApiModule {
|
||||
static register(config: ApiRuntimeConfig): DynamicModule {
|
||||
return {
|
||||
module: ApiModule,
|
||||
imports: [MarketOverviewModule.register(config)],
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
HealthService,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Controller, Get, Inject } from '@nestjs/common';
|
||||
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
BreadthResponseDto,
|
||||
FundsResponseDto,
|
||||
IndicesResponseDto,
|
||||
SectorsResponseDto,
|
||||
SummaryResponseDto,
|
||||
} from './market-overview.dto';
|
||||
import { MarketOverviewService } from './market-overview.service';
|
||||
|
||||
@ApiTags('market-overview')
|
||||
@Controller('market-overview')
|
||||
export class MarketOverviewController {
|
||||
constructor(@Inject(MarketOverviewService) private readonly service: MarketOverviewService) {}
|
||||
|
||||
@Get('summary')
|
||||
@ApiOkResponse({ type: SummaryResponseDto })
|
||||
getSummary(): Promise<SummaryResponseDto> {
|
||||
return this.service.getSummary();
|
||||
}
|
||||
|
||||
@Get('indices')
|
||||
@ApiOkResponse({ type: IndicesResponseDto })
|
||||
getIndices(): Promise<IndicesResponseDto> {
|
||||
return this.service.getIndices();
|
||||
}
|
||||
|
||||
@Get('breadth')
|
||||
@ApiOkResponse({ type: BreadthResponseDto })
|
||||
getBreadth(): Promise<BreadthResponseDto> {
|
||||
return this.service.getBreadth();
|
||||
}
|
||||
|
||||
@Get('sectors')
|
||||
@ApiOkResponse({ type: SectorsResponseDto })
|
||||
getSectors(): Promise<SectorsResponseDto> {
|
||||
return this.service.getSectors();
|
||||
}
|
||||
|
||||
@Get('funds')
|
||||
@ApiOkResponse({ type: FundsResponseDto })
|
||||
getFunds(): Promise<FundsResponseDto> {
|
||||
return this.service.getFunds();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export type MarketOverviewStatus = 'fresh' | 'delayed' | 'partial' | 'unavailable';
|
||||
export type QuoteDirection = 'up' | 'down' | 'flat';
|
||||
|
||||
export class MarketOverviewMetaDto {
|
||||
@ApiProperty({ type: String })
|
||||
snapshotId!: string;
|
||||
|
||||
@ApiProperty({ type: String, format: 'date-time' })
|
||||
asOf!: string;
|
||||
|
||||
@ApiProperty({ type: String, enum: ['tdx'] })
|
||||
source!: 'tdx';
|
||||
|
||||
@ApiProperty({ enum: ['fresh', 'delayed', 'partial', 'unavailable'] })
|
||||
status!: MarketOverviewStatus;
|
||||
|
||||
@ApiProperty({ type: [String] })
|
||||
warnings!: string[];
|
||||
}
|
||||
|
||||
export class IndexQuoteDto {
|
||||
@ApiProperty({ type: String })
|
||||
code!: string;
|
||||
|
||||
@ApiProperty({ type: String })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ type: Number })
|
||||
price!: number;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||
changePercent!: number | null;
|
||||
|
||||
@ApiProperty({ enum: ['up', 'down', 'flat'] })
|
||||
direction!: QuoteDirection;
|
||||
|
||||
@ApiProperty({ type: String })
|
||||
tradeDate!: string;
|
||||
}
|
||||
|
||||
export class IndicesDataDto {
|
||||
@ApiProperty({ type: [IndexQuoteDto] })
|
||||
items!: IndexQuoteDto[];
|
||||
}
|
||||
|
||||
export class SummaryDataDto {
|
||||
@ApiProperty({ type: String })
|
||||
verdict!: string;
|
||||
|
||||
@ApiProperty({ type: String })
|
||||
marketStatus!: string;
|
||||
|
||||
@ApiProperty({ type: String })
|
||||
volumeStatus!: string;
|
||||
|
||||
@ApiProperty({ type: String })
|
||||
sentimentStatus!: string;
|
||||
|
||||
@ApiProperty({ type: [String] })
|
||||
drivers!: string[];
|
||||
|
||||
@ApiProperty({ type: [String] })
|
||||
risks!: string[];
|
||||
}
|
||||
|
||||
export class BreadthDataDto {
|
||||
@ApiProperty({ type: Number })
|
||||
advancing!: number;
|
||||
|
||||
@ApiProperty({ type: Number })
|
||||
declining!: number;
|
||||
|
||||
@ApiProperty({ type: Number })
|
||||
unchanged!: number;
|
||||
}
|
||||
|
||||
export class SectorDataDto {
|
||||
@ApiProperty({ type: [String] })
|
||||
items!: string[];
|
||||
}
|
||||
|
||||
export class FundDataDto {
|
||||
@ApiProperty({ type: [String] })
|
||||
items!: string[];
|
||||
}
|
||||
|
||||
export class SummaryResponseDto extends MarketOverviewMetaDto {
|
||||
@ApiProperty({ type: () => SummaryDataDto, nullable: true })
|
||||
data!: SummaryDataDto | null;
|
||||
}
|
||||
|
||||
export class IndicesResponseDto extends MarketOverviewMetaDto {
|
||||
@ApiProperty({ type: () => IndicesDataDto, nullable: true })
|
||||
data!: IndicesDataDto | null;
|
||||
}
|
||||
|
||||
export class BreadthResponseDto extends MarketOverviewMetaDto {
|
||||
@ApiProperty({ type: () => BreadthDataDto, nullable: true })
|
||||
data!: BreadthDataDto | null;
|
||||
}
|
||||
|
||||
export class SectorsResponseDto extends MarketOverviewMetaDto {
|
||||
@ApiProperty({ type: () => SectorDataDto, nullable: true })
|
||||
data!: SectorDataDto | null;
|
||||
}
|
||||
|
||||
export class FundsResponseDto extends MarketOverviewMetaDto {
|
||||
@ApiProperty({ type: () => FundDataDto, nullable: true })
|
||||
data!: FundDataDto | null;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { type DynamicModule, Module } from '@nestjs/common';
|
||||
|
||||
import type { ApiRuntimeConfig } from '../../../../libs/config/src/runtime-config';
|
||||
import { MarketOverviewController } from './market-overview.controller';
|
||||
import { MarketOverviewService, TDX_CLIENT_PORT } from './market-overview.service';
|
||||
import { createTdxClient } from './tdx/tdx.client';
|
||||
|
||||
@Module({})
|
||||
export class MarketOverviewModule {
|
||||
/** 将 API 已校验的 TDX 配置注入业务模块,避免模块内部重新读取环境变量。 */
|
||||
static register(config: ApiRuntimeConfig): DynamicModule {
|
||||
return {
|
||||
module: MarketOverviewModule,
|
||||
controllers: [MarketOverviewController],
|
||||
providers: [
|
||||
MarketOverviewService,
|
||||
{ provide: TDX_CLIENT_PORT, useValue: createTdxClient(config.tdx) },
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { MarketOverviewService } from './market-overview.service';
|
||||
|
||||
const indexSeries = {
|
||||
Date: ['20260817', '20260818'],
|
||||
Open: ['3350', '3370'],
|
||||
High: ['3385', '3400'],
|
||||
Low: ['3340', '3360'],
|
||||
Close: ['3360', '3380'],
|
||||
Amount: ['100', '120'],
|
||||
Volume: ['1000', '1100'],
|
||||
};
|
||||
|
||||
const indexResult = {
|
||||
ErrorId: '0',
|
||||
Value: Object.fromEntries(
|
||||
['000001.SH', '399001.SZ', '399006.SZ'].map((code) => [code, indexSeries]),
|
||||
),
|
||||
};
|
||||
|
||||
describe('市场概览服务', () => {
|
||||
it('读取指数并返回统一快照元数据', async () => {
|
||||
const client = { request: vi.fn().mockResolvedValue(indexResult) };
|
||||
const service = new MarketOverviewService(client);
|
||||
|
||||
const response = await service.getIndices();
|
||||
|
||||
expect(response).toMatchObject({ source: 'tdx', status: 'fresh', data: { items: expect.any(Array) } });
|
||||
expect(response.snapshotId).toMatch(/^20260818-/);
|
||||
expect(client.request).toHaveBeenCalledWith('get_market_data', expect.objectContaining({ count: 5 }));
|
||||
});
|
||||
|
||||
it('对未验证的市场宽度能力返回不可用而不是伪造数据', async () => {
|
||||
const client = { request: vi.fn() };
|
||||
const service = new MarketOverviewService(client);
|
||||
|
||||
const response = await service.getBreadth();
|
||||
|
||||
expect(response).toMatchObject({ source: 'tdx', status: 'unavailable', data: null });
|
||||
expect(response.warnings[0]).toContain('市场宽度');
|
||||
expect(client.request).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
|
||||
import type {
|
||||
BreadthDataDto,
|
||||
FundDataDto,
|
||||
IndicesDataDto,
|
||||
SectorDataDto,
|
||||
SummaryDataDto,
|
||||
} from './market-overview.dto';
|
||||
import { CORE_INDEXES } from './tdx/tdx.types';
|
||||
import { mapIndexQuotes } from './tdx/tdx.mapper';
|
||||
import { TdxClientError, type TdxClient } from './tdx/tdx.client';
|
||||
import type { TdxMarketDataResult } from './tdx/tdx.types';
|
||||
|
||||
export const TDX_CLIENT_PORT = Symbol('TDX_CLIENT_PORT');
|
||||
|
||||
export type MarketOverviewResponse<TData> = {
|
||||
snapshotId: string;
|
||||
asOf: string;
|
||||
source: 'tdx';
|
||||
status: 'fresh' | 'delayed' | 'partial' | 'unavailable';
|
||||
data: TData | null;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
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 unavailable<TData>(warning: string): MarketOverviewResponse<TData> {
|
||||
return { ...createSnapshotMeta(), status: 'unavailable', data: null, warnings: [warning] };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MarketOverviewService {
|
||||
constructor(@Inject(TDX_CLIENT_PORT) private readonly tdxClient: TdxClient) {}
|
||||
|
||||
async getIndices(): Promise<MarketOverviewResponse<IndicesDataDto>> {
|
||||
const meta = createSnapshotMeta();
|
||||
try {
|
||||
const result = await this.tdxClient.request<TdxMarketDataResult>('get_market_data', {
|
||||
stock_list: CORE_INDEXES.map((item) => item.code),
|
||||
count: 5,
|
||||
dividend_type: 'none',
|
||||
period: '1d',
|
||||
});
|
||||
const items = mapIndexQuotes(result, CORE_INDEXES);
|
||||
const status = items.length === CORE_INDEXES.length ? 'fresh' : 'partial';
|
||||
return {
|
||||
...meta,
|
||||
status,
|
||||
data: { items },
|
||||
warnings: status === 'partial' ? ['部分核心指数未返回有效数据'] : [],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
...meta,
|
||||
status: 'unavailable',
|
||||
data: null,
|
||||
warnings: [this.toWarning(error, '核心指数')],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async getSummary(): Promise<MarketOverviewResponse<SummaryDataDto>> {
|
||||
const indices = await this.getIndices();
|
||||
if (indices.status === 'unavailable' || !indices.data) {
|
||||
return { ...indices, data: null };
|
||||
}
|
||||
|
||||
const changes = indices.data.items
|
||||
.map((item) => item.changePercent)
|
||||
.filter((value): value is number => value !== null);
|
||||
const averageChange = changes.length
|
||||
? Math.round((changes.reduce((sum, value) => sum + value, 0) / changes.length) * 100) / 100
|
||||
: null;
|
||||
const marketStatus = averageChange === null ? '指数数据不完整' : averageChange >= 0 ? '指数偏强' : '指数偏弱';
|
||||
const summary: SummaryDataDto = {
|
||||
verdict: averageChange === null ? '市场状态待确认' : averageChange >= 0 ? '市场维持偏强震荡' : '市场维持偏弱震荡',
|
||||
marketStatus,
|
||||
volumeStatus: '成交量口径待接入',
|
||||
sentimentStatus: '市场情绪口径待接入',
|
||||
drivers: changes.length ? [`核心指数平均涨跌幅 ${averageChange}%`] : ['核心指数数据不足'],
|
||||
risks: ['市场宽度、板块和资金指标尚未由当前 TDX 桥接层提供'],
|
||||
};
|
||||
return { ...indices, data: summary };
|
||||
}
|
||||
|
||||
async getBreadth(): Promise<MarketOverviewResponse<BreadthDataDto>> {
|
||||
return unavailable<BreadthDataDto>('当前 TDX 桥接层未提供市场宽度方法');
|
||||
}
|
||||
|
||||
async getSectors(): Promise<MarketOverviewResponse<SectorDataDto>> {
|
||||
return unavailable<SectorDataDto>('当前 TDX 桥接层未提供板块排行方法');
|
||||
}
|
||||
|
||||
async getFunds(): Promise<MarketOverviewResponse<FundDataDto>> {
|
||||
return unavailable<FundDataDto>('当前 TDX 桥接层未提供资金趋势方法');
|
||||
}
|
||||
|
||||
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}数据不可用`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { mapIndexQuotes } from './tdx.mapper';
|
||||
|
||||
describe('TDX 行情映射', () => {
|
||||
it('将 K 线数组转换为 A 股红涨绿跌的指数报价', () => {
|
||||
const result = mapIndexQuotes(
|
||||
{
|
||||
ErrorId: '0',
|
||||
Value: {
|
||||
'000001.SH': {
|
||||
Date: ['20260817', '20260818'],
|
||||
Open: ['3350.00', '3370.00'],
|
||||
High: ['3385.00', '3400.00'],
|
||||
Low: ['3340.00', '3360.00'],
|
||||
Close: ['3360.00', '3380.16'],
|
||||
Amount: ['100', '120'],
|
||||
Volume: ['1000', '1100'],
|
||||
},
|
||||
},
|
||||
},
|
||||
[{ code: '000001.SH', name: '上证指数' }],
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({
|
||||
code: '000001.SH',
|
||||
name: '上证指数',
|
||||
price: 3380.16,
|
||||
changePercent: 0.6,
|
||||
direction: 'up',
|
||||
tradeDate: '20260818',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('缺少前一日收盘价时标记为平盘并保留当前价', () => {
|
||||
const result = mapIndexQuotes(
|
||||
{
|
||||
ErrorId: '0',
|
||||
Value: {
|
||||
'399006.SZ': {
|
||||
Date: ['20260818'],
|
||||
Open: ['2100.00'],
|
||||
High: ['2110.00'],
|
||||
Low: ['2090.00'],
|
||||
Close: ['2105.00'],
|
||||
Amount: ['100'],
|
||||
Volume: ['1000'],
|
||||
},
|
||||
},
|
||||
},
|
||||
[{ code: '399006.SZ', name: '创业板指' }],
|
||||
);
|
||||
|
||||
expect(result[0]).toMatchObject({ price: 2105, changePercent: null, direction: 'flat' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { IndexQuoteDto } from '../market-overview.dto';
|
||||
import type { IndexDefinition, TdxMarketDataResult, TdxKlineSeries } from './tdx.types';
|
||||
|
||||
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 mapDirection(changePercent: number | null): IndexQuoteDto['direction'] {
|
||||
if (changePercent === null || changePercent === 0) return 'flat';
|
||||
return changePercent > 0 ? 'up' : 'down';
|
||||
}
|
||||
|
||||
function mapIndexQuote(series: TdxKlineSeries, definition: IndexDefinition): IndexQuoteDto | null {
|
||||
const lastIndex = series.Close.length - 1;
|
||||
const price = toNumber(series.Close[lastIndex]);
|
||||
const previousPrice = toNumber(series.Close[lastIndex - 1]);
|
||||
const tradeDate = series.Date[lastIndex];
|
||||
if (price === null || !tradeDate) return null;
|
||||
|
||||
const changePercent =
|
||||
previousPrice === null || previousPrice === 0
|
||||
? null
|
||||
: Math.round(((price - previousPrice) / previousPrice) * 100 * 100) / 100;
|
||||
|
||||
return {
|
||||
code: definition.code,
|
||||
name: definition.name,
|
||||
price,
|
||||
changePercent,
|
||||
direction: mapDirection(changePercent),
|
||||
tradeDate,
|
||||
};
|
||||
}
|
||||
|
||||
/** 只把已确认字段映射为领域 DTO,缺失的单个指数不阻断其他指数。 */
|
||||
export function mapIndexQuotes(
|
||||
result: TdxMarketDataResult,
|
||||
definitions: readonly IndexDefinition[],
|
||||
): IndexQuoteDto[] {
|
||||
return definitions.flatMap((definition) => {
|
||||
const series = result.Value[definition.code];
|
||||
if (!series) return [];
|
||||
const quote = mapIndexQuote(series, definition);
|
||||
return quote ? [quote] : [];
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export interface TdxKlineSeries {
|
||||
Date: string[];
|
||||
Open: string[];
|
||||
High: string[];
|
||||
Low: string[];
|
||||
Close: string[];
|
||||
Amount: string[];
|
||||
Volume: string[];
|
||||
ForwardFactor?: string[];
|
||||
}
|
||||
|
||||
export interface TdxMarketDataResult {
|
||||
ErrorId: string | number;
|
||||
Value: Record<string, TdxKlineSeries>;
|
||||
}
|
||||
|
||||
export interface IndexDefinition {
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** 首屏只使用已通过 TDX K 线只读冒烟的三大指数,代码口径统一带市场后缀。 */
|
||||
export const CORE_INDEXES: readonly IndexDefinition[] = [
|
||||
{ code: '000001.SH', name: '上证指数' },
|
||||
{ code: '399001.SZ', name: '深证成指' },
|
||||
{ code: '399006.SZ', name: '创业板指' },
|
||||
];
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import request from 'supertest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { MarketOverviewController } from '../src/market-overview/market-overview.controller';
|
||||
import { MarketOverviewService, TDX_CLIENT_PORT } from '../src/market-overview/market-overview.service';
|
||||
|
||||
describe('市场概览 HTTP API', () => {
|
||||
let app: INestApplication | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
await app?.close();
|
||||
app = undefined;
|
||||
});
|
||||
|
||||
it('按模块返回指数和 TDX 来源元数据', async () => {
|
||||
const client = {
|
||||
request: vi.fn().mockResolvedValue({
|
||||
ErrorId: '0',
|
||||
Value: {
|
||||
'000001.SH': {
|
||||
Date: ['20260817', '20260818'],
|
||||
Open: ['3350', '3370'],
|
||||
High: ['3385', '3400'],
|
||||
Low: ['3340', '3360'],
|
||||
Close: ['3360', '3380'],
|
||||
Amount: ['100', '120'],
|
||||
Volume: ['1000', '1100'],
|
||||
},
|
||||
'399001.SZ': {
|
||||
Date: ['20260817', '20260818'],
|
||||
Open: ['10000', '10100'],
|
||||
High: ['10200', '10300'],
|
||||
Low: ['9900', '10050'],
|
||||
Close: ['10000', '10200'],
|
||||
Amount: ['100', '120'],
|
||||
Volume: ['1000', '1100'],
|
||||
},
|
||||
'399006.SZ': {
|
||||
Date: ['20260817', '20260818'],
|
||||
Open: ['2100', '2110'],
|
||||
High: ['2120', '2130'],
|
||||
Low: ['2090', '2100'],
|
||||
Close: ['2100', '2120'],
|
||||
Amount: ['100', '120'],
|
||||
Volume: ['1000', '1100'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
controllers: [MarketOverviewController],
|
||||
providers: [MarketOverviewService, { 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').expect(200);
|
||||
|
||||
expect(response.body).toMatchObject({ source: 'tdx', status: 'fresh', data: { items: expect.any(Array) } });
|
||||
expect(JSON.stringify(response.body)).not.toContain('127.0.0.1');
|
||||
});
|
||||
|
||||
it('未提供的板块能力返回可读的不可用状态', async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
controllers: [MarketOverviewController],
|
||||
providers: [
|
||||
MarketOverviewService,
|
||||
{ provide: TDX_CLIENT_PORT, useValue: { request: vi.fn() } },
|
||||
],
|
||||
}).compile();
|
||||
app = moduleRef.createNestApplication();
|
||||
app.setGlobalPrefix('api/v1');
|
||||
await app.init();
|
||||
|
||||
const response = await request(app.getHttpServer()).get('/api/v1/market-overview/sectors').expect(200);
|
||||
|
||||
expect(response.body).toMatchObject({ source: 'tdx', status: 'unavailable', data: null });
|
||||
expect(response.body.warnings[0]).toContain('板块排行');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user