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 { createRedisHealthClient } from '../../../libs/infrastructure/src/redis-health.client';
|
||||||
import { HealthController } from './health.controller';
|
import { HealthController } from './health.controller';
|
||||||
import { HealthService, MYSQL_HEALTH_PORT, REDIS_HEALTH_PORT } from './health.service';
|
import { HealthService, MYSQL_HEALTH_PORT, REDIS_HEALTH_PORT } from './health.service';
|
||||||
|
import { MarketOverviewModule } from './market-overview/market-overview.module';
|
||||||
|
|
||||||
/** API 进程的只读配置注入令牌,确保测试和启动入口使用同一份显式配置。 */
|
/** API 进程的只读配置注入令牌,确保测试和启动入口使用同一份显式配置。 */
|
||||||
export const API_RUNTIME_CONFIG = Symbol('API_RUNTIME_CONFIG');
|
export const API_RUNTIME_CONFIG = Symbol('API_RUNTIME_CONFIG');
|
||||||
@@ -19,6 +20,7 @@ export class ApiModule {
|
|||||||
static register(config: ApiRuntimeConfig): DynamicModule {
|
static register(config: ApiRuntimeConfig): DynamicModule {
|
||||||
return {
|
return {
|
||||||
module: ApiModule,
|
module: ApiModule,
|
||||||
|
imports: [MarketOverviewModule.register(config)],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
providers: [
|
providers: [
|
||||||
HealthService,
|
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('板块排行');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,41 @@
|
|||||||
{
|
{
|
||||||
"components": {
|
"components": {
|
||||||
"schemas": {
|
"schemas": {
|
||||||
|
"BreadthDataDto": {
|
||||||
|
"properties": {
|
||||||
|
"advancing": { "type": "number" },
|
||||||
|
"declining": { "type": "number" },
|
||||||
|
"unchanged": { "type": "number" }
|
||||||
|
},
|
||||||
|
"required": ["advancing", "declining", "unchanged"],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"BreadthResponseDto": {
|
||||||
|
"properties": {
|
||||||
|
"asOf": { "format": "date-time", "type": "string" },
|
||||||
|
"data": {
|
||||||
|
"allOf": [{ "$ref": "#/components/schemas/BreadthDataDto" }],
|
||||||
|
"nullable": true,
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"snapshotId": { "type": "string" },
|
||||||
|
"source": { "enum": ["tdx"], "type": "string" },
|
||||||
|
"status": {
|
||||||
|
"enum": ["fresh", "delayed", "partial", "unavailable"],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"warnings": { "items": { "type": "string" }, "type": "array" }
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"snapshotId",
|
||||||
|
"asOf",
|
||||||
|
"source",
|
||||||
|
"status",
|
||||||
|
"warnings",
|
||||||
|
"data"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"DependencyHealthDto": {
|
"DependencyHealthDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"detail": { "type": "string" },
|
"detail": { "type": "string" },
|
||||||
@@ -12,6 +47,39 @@
|
|||||||
"required": ["status", "detail"],
|
"required": ["status", "detail"],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"FundDataDto": {
|
||||||
|
"properties": {
|
||||||
|
"items": { "items": { "type": "string" }, "type": "array" }
|
||||||
|
},
|
||||||
|
"required": ["items"],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"FundsResponseDto": {
|
||||||
|
"properties": {
|
||||||
|
"asOf": { "format": "date-time", "type": "string" },
|
||||||
|
"data": {
|
||||||
|
"allOf": [{ "$ref": "#/components/schemas/FundDataDto" }],
|
||||||
|
"nullable": true,
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"snapshotId": { "type": "string" },
|
||||||
|
"source": { "enum": ["tdx"], "type": "string" },
|
||||||
|
"status": {
|
||||||
|
"enum": ["fresh", "delayed", "partial", "unavailable"],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"warnings": { "items": { "type": "string" }, "type": "array" }
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"snapshotId",
|
||||||
|
"asOf",
|
||||||
|
"source",
|
||||||
|
"status",
|
||||||
|
"warnings",
|
||||||
|
"data"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"HealthDependenciesDto": {
|
"HealthDependenciesDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"mysql": { "$ref": "#/components/schemas/DependencyHealthDto" },
|
"mysql": { "$ref": "#/components/schemas/DependencyHealthDto" },
|
||||||
@@ -32,6 +100,132 @@
|
|||||||
},
|
},
|
||||||
"required": ["status", "process", "checkedAt", "dependencies"],
|
"required": ["status", "process", "checkedAt", "dependencies"],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
|
},
|
||||||
|
"IndexQuoteDto": {
|
||||||
|
"properties": {
|
||||||
|
"changePercent": { "nullable": true, "type": "number" },
|
||||||
|
"code": { "type": "string" },
|
||||||
|
"direction": { "enum": ["up", "down", "flat"], "type": "string" },
|
||||||
|
"name": { "type": "string" },
|
||||||
|
"price": { "type": "number" },
|
||||||
|
"tradeDate": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["code", "name", "price", "direction", "tradeDate"],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"IndicesDataDto": {
|
||||||
|
"properties": {
|
||||||
|
"items": {
|
||||||
|
"items": { "$ref": "#/components/schemas/IndexQuoteDto" },
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["items"],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"IndicesResponseDto": {
|
||||||
|
"properties": {
|
||||||
|
"asOf": { "format": "date-time", "type": "string" },
|
||||||
|
"data": {
|
||||||
|
"allOf": [{ "$ref": "#/components/schemas/IndicesDataDto" }],
|
||||||
|
"nullable": true,
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"snapshotId": { "type": "string" },
|
||||||
|
"source": { "enum": ["tdx"], "type": "string" },
|
||||||
|
"status": {
|
||||||
|
"enum": ["fresh", "delayed", "partial", "unavailable"],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"warnings": { "items": { "type": "string" }, "type": "array" }
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"snapshotId",
|
||||||
|
"asOf",
|
||||||
|
"source",
|
||||||
|
"status",
|
||||||
|
"warnings",
|
||||||
|
"data"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"SectorDataDto": {
|
||||||
|
"properties": {
|
||||||
|
"items": { "items": { "type": "string" }, "type": "array" }
|
||||||
|
},
|
||||||
|
"required": ["items"],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"SectorsResponseDto": {
|
||||||
|
"properties": {
|
||||||
|
"asOf": { "format": "date-time", "type": "string" },
|
||||||
|
"data": {
|
||||||
|
"allOf": [{ "$ref": "#/components/schemas/SectorDataDto" }],
|
||||||
|
"nullable": true,
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"snapshotId": { "type": "string" },
|
||||||
|
"source": { "enum": ["tdx"], "type": "string" },
|
||||||
|
"status": {
|
||||||
|
"enum": ["fresh", "delayed", "partial", "unavailable"],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"warnings": { "items": { "type": "string" }, "type": "array" }
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"snapshotId",
|
||||||
|
"asOf",
|
||||||
|
"source",
|
||||||
|
"status",
|
||||||
|
"warnings",
|
||||||
|
"data"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"SummaryDataDto": {
|
||||||
|
"properties": {
|
||||||
|
"drivers": { "items": { "type": "string" }, "type": "array" },
|
||||||
|
"marketStatus": { "type": "string" },
|
||||||
|
"risks": { "items": { "type": "string" }, "type": "array" },
|
||||||
|
"sentimentStatus": { "type": "string" },
|
||||||
|
"verdict": { "type": "string" },
|
||||||
|
"volumeStatus": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"verdict",
|
||||||
|
"marketStatus",
|
||||||
|
"volumeStatus",
|
||||||
|
"sentimentStatus",
|
||||||
|
"drivers",
|
||||||
|
"risks"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"SummaryResponseDto": {
|
||||||
|
"properties": {
|
||||||
|
"asOf": { "format": "date-time", "type": "string" },
|
||||||
|
"data": {
|
||||||
|
"allOf": [{ "$ref": "#/components/schemas/SummaryDataDto" }],
|
||||||
|
"nullable": true,
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"snapshotId": { "type": "string" },
|
||||||
|
"source": { "enum": ["tdx"], "type": "string" },
|
||||||
|
"status": {
|
||||||
|
"enum": ["fresh", "delayed", "partial", "unavailable"],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"warnings": { "items": { "type": "string" }, "type": "array" }
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"snapshotId",
|
||||||
|
"asOf",
|
||||||
|
"source",
|
||||||
|
"status",
|
||||||
|
"warnings",
|
||||||
|
"data"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -67,6 +261,91 @@
|
|||||||
},
|
},
|
||||||
"tags": ["health"]
|
"tags": ["health"]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/market-overview/breadth": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "MarketOverviewController_getBreadth",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/BreadthResponseDto" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": ["market-overview"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/market-overview/funds": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "MarketOverviewController_getFunds",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/FundsResponseDto" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": ["market-overview"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/market-overview/indices": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "MarketOverviewController_getIndices",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/IndicesResponseDto" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": ["market-overview"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/market-overview/sectors": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "MarketOverviewController_getSectors",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/SectorsResponseDto" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": ["market-overview"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/market-overview/summary": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "MarketOverviewController_getSummary",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/SummaryResponseDto" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": ["market-overview"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"servers": [],
|
"servers": [],
|
||||||
|
|||||||
@@ -8,17 +8,21 @@ import path from 'node:path';
|
|||||||
import { format } from 'prettier';
|
import { format } from 'prettier';
|
||||||
|
|
||||||
import { HealthController } from '../apps/api/src/health.controller';
|
import { HealthController } from '../apps/api/src/health.controller';
|
||||||
|
import { MarketOverviewController } from '../apps/api/src/market-overview/market-overview.controller';
|
||||||
import {
|
import {
|
||||||
HealthService,
|
HealthService,
|
||||||
MYSQL_HEALTH_PORT,
|
MYSQL_HEALTH_PORT,
|
||||||
REDIS_HEALTH_PORT,
|
REDIS_HEALTH_PORT,
|
||||||
} from '../apps/api/src/health.service';
|
} from '../apps/api/src/health.service';
|
||||||
|
import { MarketOverviewService, TDX_CLIENT_PORT } from '../apps/api/src/market-overview/market-overview.service';
|
||||||
import { DisabledHealthClient } from '../libs/infrastructure/src/disabled-health.client';
|
import { DisabledHealthClient } from '../libs/infrastructure/src/disabled-health.client';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [HealthController],
|
controllers: [HealthController, MarketOverviewController],
|
||||||
providers: [
|
providers: [
|
||||||
HealthService,
|
HealthService,
|
||||||
|
MarketOverviewService,
|
||||||
|
{ provide: TDX_CLIENT_PORT, useValue: { request: async () => ({}) } },
|
||||||
{ provide: REDIS_HEALTH_PORT, useValue: new DisabledHealthClient('文档生成不连接 Redis') },
|
{ provide: REDIS_HEALTH_PORT, useValue: new DisabledHealthClient('文档生成不连接 Redis') },
|
||||||
{ provide: MYSQL_HEALTH_PORT, useValue: new DisabledHealthClient('文档生成不连接 MySQL') },
|
{ provide: MYSQL_HEALTH_PORT, useValue: new DisabledHealthClient('文档生成不连接 MySQL') },
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user