feat(FEAT-MO-005): 增加行业题材板块排行接口

This commit is contained in:
2026-08-25 01:51:23 +08:00
parent b6a9f40ed8
commit 8174f3f698
8 changed files with 459 additions and 1 deletions
@@ -9,6 +9,12 @@ 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';
import { createAktoolsStructureClient } from './structure/aktools-structure.client';
import { MarketStructureController } from './structure/market-structure.controller';
import {
MARKET_STRUCTURE_SOURCE_PORT,
MarketStructureService,
} from './structure/market-structure.service';
@Module({})
export class MarketOverviewModule {
@@ -16,13 +22,23 @@ export class MarketOverviewModule {
static register(config: ApiRuntimeConfig): DynamicModule {
return {
module: MarketOverviewModule,
controllers: [MarketOverviewController, MarketDetailController, ShortTermMarketController],
controllers: [
MarketOverviewController,
MarketDetailController,
ShortTermMarketController,
MarketStructureController,
],
providers: [
MarketOverviewService,
MarketDetailService,
MarketStructureService,
{ provide: TDX_CLIENT_PORT, useValue: createTdxClient(config.tdx) },
ShortTermMarketService,
{ provide: AKTOOLS_CLIENT_PORT, useValue: createAktoolsClient(config.aktools) },
{
provide: MARKET_STRUCTURE_SOURCE_PORT,
useValue: createAktoolsStructureClient(config.aktools),
},
],
};
}
@@ -0,0 +1,28 @@
import { BadRequestException, Controller, Get, Query } from '@nestjs/common';
import { ApiOkResponse, ApiQuery, ApiTags } from '@nestjs/swagger';
import { SectorRankingsResponseDto } from './market-structure.dto';
import { MarketStructureService, parseSectorRankingQuery } from './market-structure.service';
@ApiTags('market-overview-structure')
@Controller('market-overview')
export class MarketStructureController {
constructor(private readonly service: MarketStructureService) {}
@Get('sector-rankings')
@ApiQuery({ name: 'category', required: false, enum: ['industry', 'concept', 'board'] })
@ApiQuery({ name: 'sort', required: false, enum: ['rise', 'fall', 'turnover'] })
@ApiQuery({ name: 'limit', required: false, type: Number, minimum: 1, maximum: 100 })
@ApiOkResponse({ type: SectorRankingsResponseDto })
getSectorRankings(
@Query('category') category?: string,
@Query('sort') sort?: string,
@Query('limit') limit?: string,
): Promise<SectorRankingsResponseDto> {
try {
return this.service.getSectorRankings(parseSectorRankingQuery(category, sort, limit));
} catch (error) {
throw new BadRequestException(error instanceof Error ? error.message : '排行参数无效');
}
}
}
@@ -0,0 +1,49 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { MarketOverviewMetaDto } from '../market-overview.dto';
import type { StructureCategory, StructureSort } from './market-structure.types';
export class SectorRankingItemDto {
@ApiProperty({ enum: ['industry', 'concept', 'board'] })
category!: StructureCategory;
@ApiProperty({ type: String })
code!: string;
@ApiProperty({ type: String })
name!: string;
@ApiPropertyOptional({ type: Number, nullable: true })
changePercent!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true, description: '亿元' })
turnoverAmount!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true })
advancingCount!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true })
decliningCount!: number | null;
@ApiPropertyOptional({ type: String, nullable: true })
leader!: string | null;
@ApiPropertyOptional({ type: String, nullable: true, example: '20260825' })
tradeDate!: string | null;
}
export class SectorRankingsDataDto {
@ApiProperty({ type: [SectorRankingItemDto] })
items!: SectorRankingItemDto[];
@ApiProperty({ enum: ['rise', 'fall', 'turnover'] })
sort!: Extract<StructureSort, 'rise' | 'fall' | 'turnover'>;
@ApiProperty({ enum: ['industry', 'concept', 'board'] })
category!: StructureCategory;
}
export class SectorRankingsResponseDto extends MarketOverviewMetaDto {
@ApiProperty({ type: () => SectorRankingsDataDto, nullable: true })
data!: SectorRankingsDataDto | null;
}
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import { MarketStructureService, parseSectorRankingQuery } from './market-structure.service';
import type { MarketStructureSourceClient } from './market-structure.types';
const source: MarketStructureSourceClient = {
getSectorRows: async () => [
{ 代码: 'BK2', 名称: '弱势', 涨跌幅: '-2', 成交额: '200000000', 日期: '20260825' },
{ 代码: 'BK1', 名称: '强势', 涨跌幅: '5', 成交额: '100000000', 日期: '20260825' },
],
getCapitalFlowRows: async () => [],
getMarketFundFlowRows: async () => [],
};
describe('行业题材排行服务', () => {
it('校验分类、排序和 1-100 数量范围', () => {
expect(parseSectorRankingQuery('concept', 'fall', '10')).toEqual({
category: 'concept',
sort: 'fall',
limit: 10,
});
expect(() => parseSectorRankingQuery('stock', 'rise', '10')).toThrow('分类参数无效');
expect(() => parseSectorRankingQuery('industry', 'unknown', '10')).toThrow('排序参数无效');
expect(() => parseSectorRankingQuery('industry', 'rise', '0')).toThrow('数量参数无效');
expect(() => parseSectorRankingQuery('industry', 'rise', '101')).toThrow('数量参数无效');
});
it('按涨幅排序并截取指定数量', async () => {
const service = new MarketStructureService(source);
const response = await service.getSectorRankings({
category: 'industry',
sort: 'rise',
limit: 1,
});
expect(response.status).toBe('fresh');
expect(response.data?.items).toHaveLength(1);
expect(response.data?.items[0]).toMatchObject({ code: 'BK1', changePercent: 5 });
});
it('上游失败时返回不可用且不泄露异常内容', async () => {
const failing: MarketStructureSourceClient = {
...source,
getSectorRows: async () => {
throw new Error('secret upstream URL and payload');
},
};
const service = new MarketStructureService(failing);
const response = await service.getSectorRankings({
category: 'industry',
sort: 'rise',
limit: 10,
});
expect(response).toMatchObject({ status: 'unavailable', data: null });
expect(response.warnings).toEqual(['行业排行数据不可用']);
});
});
@@ -0,0 +1,121 @@
import { Inject, Injectable } from '@nestjs/common';
import type { MarketOverviewStatus } from '../market-overview.dto';
import { mapSectorRanking } from './market-structure.mapper';
import type {
SectorRankingRecord,
StructureCategory,
StructureSort,
MarketStructureSourceClient,
} from './market-structure.types';
import type { SectorRankingsResponseDto } from './market-structure.dto';
export const MARKET_STRUCTURE_SOURCE_PORT = Symbol('MARKET_STRUCTURE_SOURCE_PORT');
export interface SectorRankingQuery {
category: StructureCategory;
sort: Extract<StructureSort, 'rise' | 'fall' | 'turnover'>;
limit: number;
}
const CATEGORIES = new Set<StructureCategory>(['industry', 'concept', 'board']);
const SORTS = new Set<SectorRankingQuery['sort']>(['rise', 'fall', 'turnover']);
export function parseSectorRankingQuery(
category = 'industry',
sort = 'rise',
limit = '10',
): SectorRankingQuery {
if (!CATEGORIES.has(category as StructureCategory)) throw new Error('分类参数无效');
if (!SORTS.has(sort as SectorRankingQuery['sort'])) throw new Error('排序参数无效');
if (!/^\d+$/.test(limit)) throw new Error('数量参数无效');
const parsedLimit = Number(limit);
if (!Number.isSafeInteger(parsedLimit) || parsedLimit < 1 || parsedLimit > 100) {
throw new Error('数量参数无效');
}
return {
category: category as StructureCategory,
sort: sort as SectorRankingQuery['sort'],
limit: parsedLimit,
};
}
function snapshotMeta(
source: 'aktools',
): Pick<SectorRankingsResponseDto, 'snapshotId' | 'asOf' | 'source'> {
const asOf = new Date().toISOString();
return {
snapshotId: asOf.replace(/[-:TZ.]/g, '').slice(0, 15),
asOf,
source,
};
}
function sortItems(
items: readonly SectorRankingRecord[],
sort: SectorRankingQuery['sort'],
): SectorRankingRecord[] {
const valueFor = (item: SectorRankingRecord): number => {
if (sort === 'turnover') return item.turnoverAmount ?? Number.NEGATIVE_INFINITY;
return item.changePercent ?? Number.NEGATIVE_INFINITY;
};
const direction = sort === 'fall' ? 1 : -1;
return [...items].sort((left, right) => {
const difference = (valueFor(left) - valueFor(right)) * direction;
return difference || left.name.localeCompare(right.name, 'zh-CN');
});
}
function statusFor(items: readonly SectorRankingRecord[], rawCount: number): MarketOverviewStatus {
if (!items.length) return 'unavailable';
if (items.length < rawCount || items.some((item) => item.tradeDate === null)) return 'partial';
return 'fresh';
}
@Injectable()
export class MarketStructureService {
constructor(
@Inject(MARKET_STRUCTURE_SOURCE_PORT)
private readonly source: MarketStructureSourceClient,
) {}
async getSectorRankings(query: SectorRankingQuery): Promise<SectorRankingsResponseDto> {
const meta = snapshotMeta('aktools');
try {
const rows = await this.source.getSectorRows(query.category);
const items = rows.flatMap((row) => {
const mapped = mapSectorRanking(row, query.category, this.readTradeDate(row));
return mapped ? [mapped] : [];
});
const sortedAll = sortItems(items, query.sort);
const sorted = sortedAll.slice(0, query.limit);
const status = statusFor(sortedAll, rows.length);
return {
...meta,
status,
data:
status === 'unavailable'
? null
: { items: sorted, sort: query.sort, category: query.category },
warnings:
status === 'fresh'
? []
: status === 'partial'
? ['部分行业或题材排行字段不可用']
: ['行业排行数据不可用'],
};
} catch {
return {
...meta,
status: 'unavailable',
data: null,
warnings: ['行业排行数据不可用'],
};
}
}
private readTradeDate(row: Readonly<Record<string, unknown>>): string | null {
const value = row['日期'] ?? row['交易日期'] ?? row['tradeDate'];
return typeof value === 'string' && /^\d{8}$/.test(value) ? value : null;
}
}