feat(FEAT-MO-005): 增加行业题材板块排行接口
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -271,6 +271,66 @@
|
||||
"required": ["code", "name", "changePercent", "direction", "tradeDate"],
|
||||
"type": "object"
|
||||
},
|
||||
"SectorRankingItemDto": {
|
||||
"properties": {
|
||||
"advancingCount": { "nullable": true, "type": "number" },
|
||||
"category": {
|
||||
"enum": ["industry", "concept", "board"],
|
||||
"type": "string"
|
||||
},
|
||||
"changePercent": { "nullable": true, "type": "number" },
|
||||
"code": { "type": "string" },
|
||||
"decliningCount": { "nullable": true, "type": "number" },
|
||||
"leader": { "nullable": true, "type": "string" },
|
||||
"name": { "type": "string" },
|
||||
"tradeDate": {
|
||||
"example": "20260825",
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"turnoverAmount": {
|
||||
"description": "亿元",
|
||||
"nullable": true,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": ["category", "code", "name"],
|
||||
"type": "object"
|
||||
},
|
||||
"SectorRankingsDataDto": {
|
||||
"properties": {
|
||||
"category": {
|
||||
"enum": ["industry", "concept", "board"],
|
||||
"type": "string"
|
||||
},
|
||||
"items": {
|
||||
"items": { "$ref": "#/components/schemas/SectorRankingItemDto" },
|
||||
"type": "array"
|
||||
},
|
||||
"sort": { "enum": ["rise", "fall", "turnover"], "type": "string" }
|
||||
},
|
||||
"required": ["items", "sort", "category"],
|
||||
"type": "object"
|
||||
},
|
||||
"SectorRankingsResponseDto": {
|
||||
"properties": {
|
||||
"asOf": { "format": "date-time", "type": "string" },
|
||||
"data": {
|
||||
"allOf": [{ "$ref": "#/components/schemas/SectorRankingsDataDto" }],
|
||||
"nullable": true,
|
||||
"type": "object"
|
||||
},
|
||||
"snapshotId": { "type": "string" },
|
||||
"source": { "enum": ["tdx", "aktools", "mixed"], "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"
|
||||
},
|
||||
"SectorsResponseDto": {
|
||||
"properties": {
|
||||
"asOf": { "format": "date-time", "type": "string" },
|
||||
@@ -553,6 +613,47 @@
|
||||
"tags": ["market-overview-detail"]
|
||||
}
|
||||
},
|
||||
"/api/v1/market-overview/sector-rankings": {
|
||||
"get": {
|
||||
"operationId": "MarketStructureController_getSectorRankings",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "limit",
|
||||
"required": false,
|
||||
"schema": { "maximum": 100, "minimum": 1, "type": "number" }
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "sort",
|
||||
"required": false,
|
||||
"schema": { "enum": ["rise", "fall", "turnover"], "type": "string" }
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "category",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"enum": ["industry", "concept", "board"],
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SectorRankingsResponseDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"tags": ["market-overview-structure"]
|
||||
}
|
||||
},
|
||||
"/api/v1/market-overview/sectors": {
|
||||
"get": {
|
||||
"operationId": "MarketOverviewController_getSectors",
|
||||
|
||||
@@ -11,6 +11,12 @@ import { HealthController } from '../apps/api/src/health.controller';
|
||||
import { MarketOverviewController } from '../apps/api/src/market-overview/market-overview.controller';
|
||||
import { MarketDetailController } from '../apps/api/src/market-overview/detail/market-detail.controller';
|
||||
import { ShortTermMarketController } from '../apps/api/src/market-overview/short-term.controller';
|
||||
import { MarketStructureController } from '../apps/api/src/market-overview/structure/market-structure.controller';
|
||||
import { createAktoolsStructureClient } from '../apps/api/src/market-overview/structure/aktools-structure.client';
|
||||
import {
|
||||
MARKET_STRUCTURE_SOURCE_PORT,
|
||||
MarketStructureService,
|
||||
} from '../apps/api/src/market-overview/structure/market-structure.service';
|
||||
import {
|
||||
HealthService,
|
||||
MYSQL_HEALTH_PORT,
|
||||
@@ -34,6 +40,7 @@ import { DisabledHealthClient } from '../libs/infrastructure/src/disabled-health
|
||||
MarketOverviewController,
|
||||
MarketDetailController,
|
||||
ShortTermMarketController,
|
||||
MarketStructureController,
|
||||
],
|
||||
providers: [
|
||||
HealthService,
|
||||
@@ -41,6 +48,7 @@ import { DisabledHealthClient } from '../libs/infrastructure/src/disabled-health
|
||||
MarketDetailService,
|
||||
{ provide: TDX_CLIENT_PORT, useValue: { request: async () => ({}) } },
|
||||
ShortTermMarketService,
|
||||
MarketStructureService,
|
||||
{
|
||||
provide: AKTOOLS_CLIENT_PORT,
|
||||
useValue: createAktoolsClient({
|
||||
@@ -49,6 +57,14 @@ import { DisabledHealthClient } from '../libs/infrastructure/src/disabled-health
|
||||
timeoutMs: 10_000,
|
||||
}),
|
||||
},
|
||||
{
|
||||
provide: MARKET_STRUCTURE_SOURCE_PORT,
|
||||
useValue: createAktoolsStructureClient({
|
||||
enabled: false,
|
||||
baseUrl: 'http://127.0.0.1:8080',
|
||||
timeoutMs: 10_000,
|
||||
}),
|
||||
},
|
||||
{ provide: REDIS_HEALTH_PORT, useValue: new DisabledHealthClient('文档生成不连接 Redis') },
|
||||
{ provide: MYSQL_HEALTH_PORT, useValue: new DisabledHealthClient('文档生成不连接 MySQL') },
|
||||
],
|
||||
|
||||
@@ -84,6 +84,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/market-overview/sector-rankings': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations['MarketStructureController_getSectorRankings'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/market-overview/sectors': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -322,6 +338,38 @@ export interface components {
|
||||
name: string;
|
||||
tradeDate: string;
|
||||
};
|
||||
SectorRankingItemDto: {
|
||||
advancingCount?: number | null;
|
||||
/** @enum {string} */
|
||||
category: 'industry' | 'concept' | 'board';
|
||||
changePercent?: number | null;
|
||||
code: string;
|
||||
decliningCount?: number | null;
|
||||
leader?: string | null;
|
||||
name: string;
|
||||
/** @example 20260825 */
|
||||
tradeDate?: string | null;
|
||||
/** @description 亿元 */
|
||||
turnoverAmount?: number | null;
|
||||
};
|
||||
SectorRankingsDataDto: {
|
||||
/** @enum {string} */
|
||||
category: 'industry' | 'concept' | 'board';
|
||||
items: components['schemas']['SectorRankingItemDto'][];
|
||||
/** @enum {string} */
|
||||
sort: 'rise' | 'fall' | 'turnover';
|
||||
};
|
||||
SectorRankingsResponseDto: {
|
||||
/** Format: date-time */
|
||||
asOf: string;
|
||||
data: components['schemas']['SectorRankingsDataDto'] | null;
|
||||
snapshotId: string;
|
||||
/** @enum {string} */
|
||||
source: 'tdx' | 'aktools' | 'mixed';
|
||||
/** @enum {string} */
|
||||
status: 'fresh' | 'delayed' | 'partial' | 'unavailable';
|
||||
warnings: string[];
|
||||
};
|
||||
SectorsResponseDto: {
|
||||
/** Format: date-time */
|
||||
asOf: string;
|
||||
@@ -524,6 +572,29 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
MarketStructureController_getSectorRankings: {
|
||||
parameters: {
|
||||
query?: {
|
||||
limit?: number;
|
||||
sort?: 'rise' | 'fall' | 'turnover';
|
||||
category?: 'industry' | 'concept' | 'board';
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['SectorRankingsResponseDto'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
MarketOverviewController_getSectors: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
Reference in New Issue
Block a user