feat(FEAT-MO-001): 增加市场概览分模块接口

This commit is contained in:
2026-08-18 13:31:24 +08:00
parent c2fa929936
commit 15a25002ce
12 changed files with 834 additions and 1 deletions
+2
View File
@@ -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('板块排行');
});
});