feat(FEAT-MO-005): 增加行业题材资金流向接口
This commit is contained in:
@@ -1,8 +1,12 @@
|
||||
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';
|
||||
import { CapitalFlowResponseDto, SectorRankingsResponseDto } from './market-structure.dto';
|
||||
import {
|
||||
MarketStructureService,
|
||||
parseCapitalFlowQuery,
|
||||
parseSectorRankingQuery,
|
||||
} from './market-structure.service';
|
||||
|
||||
@ApiTags('market-overview-structure')
|
||||
@Controller('market-overview')
|
||||
@@ -25,4 +29,21 @@ export class MarketStructureController {
|
||||
throw new BadRequestException(error instanceof Error ? error.message : '排行参数无效');
|
||||
}
|
||||
}
|
||||
|
||||
@Get('capital-flow')
|
||||
@ApiQuery({ name: 'category', required: false, enum: ['industry', 'concept', 'board'] })
|
||||
@ApiQuery({ name: 'sort', required: false, enum: ['inflow', 'outflow'] })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number, minimum: 1, maximum: 100 })
|
||||
@ApiOkResponse({ type: CapitalFlowResponseDto })
|
||||
getCapitalFlow(
|
||||
@Query('category') category?: string,
|
||||
@Query('sort') sort?: string,
|
||||
@Query('limit') limit?: string,
|
||||
): Promise<CapitalFlowResponseDto> {
|
||||
try {
|
||||
return this.service.getCapitalFlow(parseCapitalFlowQuery(category, sort, limit));
|
||||
} catch (error) {
|
||||
throw new BadRequestException(error instanceof Error ? error.message : '资金流参数无效');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,3 +47,45 @@ export class SectorRankingsResponseDto extends MarketOverviewMetaDto {
|
||||
@ApiProperty({ type: () => SectorRankingsDataDto, nullable: true })
|
||||
data!: SectorRankingsDataDto | null;
|
||||
}
|
||||
|
||||
export class CapitalFlowItemDto {
|
||||
@ApiProperty({ enum: ['industry', 'concept', 'board'] })
|
||||
category!: StructureCategory;
|
||||
|
||||
@ApiPropertyOptional({ type: String, nullable: true })
|
||||
code!: string | null;
|
||||
|
||||
@ApiProperty({ type: String })
|
||||
name!: string;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, nullable: true, description: '亿元' })
|
||||
netInflow!: number | null;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, nullable: true, description: '亿元' })
|
||||
netOutflow!: number | null;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, nullable: true, description: '亿元' })
|
||||
turnoverAmount!: number | null;
|
||||
|
||||
@ApiPropertyOptional({ type: String, nullable: true })
|
||||
tradeDate!: string | null;
|
||||
|
||||
@ApiProperty({ type: String })
|
||||
flowDefinition!: string;
|
||||
}
|
||||
|
||||
export class CapitalFlowDataDto {
|
||||
@ApiProperty({ type: [CapitalFlowItemDto] })
|
||||
items!: CapitalFlowItemDto[];
|
||||
|
||||
@ApiProperty({ enum: ['inflow', 'outflow'] })
|
||||
sort!: Extract<StructureSort, 'inflow' | 'outflow'>;
|
||||
|
||||
@ApiProperty({ enum: ['industry', 'concept', 'board'] })
|
||||
category!: StructureCategory;
|
||||
}
|
||||
|
||||
export class CapitalFlowResponseDto extends MarketOverviewMetaDto {
|
||||
@ApiProperty({ type: () => CapitalFlowDataDto, nullable: true })
|
||||
data!: CapitalFlowDataDto | null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { MarketStructureService, parseSectorRankingQuery } from './market-structure.service';
|
||||
import {
|
||||
MarketStructureService,
|
||||
parseCapitalFlowQuery,
|
||||
parseSectorRankingQuery,
|
||||
} from './market-structure.service';
|
||||
import type { MarketStructureSourceClient } from './market-structure.types';
|
||||
|
||||
const source: MarketStructureSourceClient = {
|
||||
@@ -8,7 +12,10 @@ const source: MarketStructureSourceClient = {
|
||||
{ 代码: 'BK2', 名称: '弱势', 涨跌幅: '-2', 成交额: '200000000', 日期: '20260825' },
|
||||
{ 代码: 'BK1', 名称: '强势', 涨跌幅: '5', 成交额: '100000000', 日期: '20260825' },
|
||||
],
|
||||
getCapitalFlowRows: async () => [],
|
||||
getCapitalFlowRows: async () => [
|
||||
{ 代码: 'BK1', 名称: '强势', '今日主力净流入-净额': '200000000', 日期: '20260825' },
|
||||
{ 代码: 'BK2', 名称: '弱势', '今日主力净流入-净额': '-100000000', 日期: '20260825' },
|
||||
],
|
||||
getMarketFundFlowRows: async () => [],
|
||||
};
|
||||
|
||||
@@ -53,4 +60,47 @@ describe('行业题材排行服务', () => {
|
||||
expect(response).toMatchObject({ status: 'unavailable', data: null });
|
||||
expect(response.warnings).toEqual(['行业排行数据不可用']);
|
||||
});
|
||||
|
||||
it('校验资金流分类、排序和数量参数', () => {
|
||||
expect(parseCapitalFlowQuery('concept', 'outflow', '20')).toEqual({
|
||||
category: 'concept',
|
||||
sort: 'outflow',
|
||||
limit: 20,
|
||||
});
|
||||
expect(() => parseCapitalFlowQuery('stock', 'inflow', '10')).toThrow('分类参数无效');
|
||||
expect(() => parseCapitalFlowQuery('industry', 'unknown', '10')).toThrow('排序参数无效');
|
||||
expect(() => parseCapitalFlowQuery('industry', 'inflow', '101')).toThrow('数量参数无效');
|
||||
});
|
||||
|
||||
it('按净流入排序并保留资金口径', async () => {
|
||||
const service = new MarketStructureService(source);
|
||||
const response = await service.getCapitalFlow({
|
||||
category: 'industry',
|
||||
sort: 'inflow',
|
||||
limit: 10,
|
||||
});
|
||||
expect(response.status).toBe('fresh');
|
||||
expect(response.data?.items[0]).toMatchObject({
|
||||
code: 'BK1',
|
||||
netInflow: 2,
|
||||
flowDefinition: '今日主力净流入-净额',
|
||||
});
|
||||
});
|
||||
|
||||
it('缺少上游净流入字段时标记 partial,不填充零值', async () => {
|
||||
const partial: MarketStructureSourceClient = {
|
||||
...source,
|
||||
getCapitalFlowRows: async () => [{ 代码: 'BK1', 名称: '未知', 成交额: '100000000' }],
|
||||
};
|
||||
const response = await new MarketStructureService(partial).getCapitalFlow({
|
||||
category: 'industry',
|
||||
sort: 'inflow',
|
||||
limit: 10,
|
||||
});
|
||||
expect(response.status).toBe('partial');
|
||||
expect(response.data?.items[0]).toMatchObject({
|
||||
netInflow: null,
|
||||
flowDefinition: 'unavailable',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
|
||||
import type { MarketOverviewStatus } from '../market-overview.dto';
|
||||
import { mapSectorRanking } from './market-structure.mapper';
|
||||
import { mapCapitalFlow, mapSectorRanking } from './market-structure.mapper';
|
||||
import type {
|
||||
CapitalFlowRecord,
|
||||
SectorRankingRecord,
|
||||
StructureCategory,
|
||||
StructureSort,
|
||||
MarketStructureSourceClient,
|
||||
} from './market-structure.types';
|
||||
import type { SectorRankingsResponseDto } from './market-structure.dto';
|
||||
import type { CapitalFlowResponseDto, SectorRankingsResponseDto } from './market-structure.dto';
|
||||
|
||||
export const MARKET_STRUCTURE_SOURCE_PORT = Symbol('MARKET_STRUCTURE_SOURCE_PORT');
|
||||
|
||||
@@ -18,8 +19,15 @@ export interface SectorRankingQuery {
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface CapitalFlowQuery {
|
||||
category: StructureCategory;
|
||||
sort: Extract<StructureSort, 'inflow' | 'outflow'>;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
const CATEGORIES = new Set<StructureCategory>(['industry', 'concept', 'board']);
|
||||
const SORTS = new Set<SectorRankingQuery['sort']>(['rise', 'fall', 'turnover']);
|
||||
const FLOW_SORTS = new Set<CapitalFlowQuery['sort']>(['inflow', 'outflow']);
|
||||
|
||||
export function parseSectorRankingQuery(
|
||||
category = 'industry',
|
||||
@@ -40,6 +48,25 @@ export function parseSectorRankingQuery(
|
||||
};
|
||||
}
|
||||
|
||||
export function parseCapitalFlowQuery(
|
||||
category = 'industry',
|
||||
sort = 'inflow',
|
||||
limit = '10',
|
||||
): CapitalFlowQuery {
|
||||
if (!CATEGORIES.has(category as StructureCategory)) throw new Error('分类参数无效');
|
||||
if (!FLOW_SORTS.has(sort as CapitalFlowQuery['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 CapitalFlowQuery['sort'],
|
||||
limit: parsedLimit,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshotMeta(
|
||||
source: 'aktools',
|
||||
): Pick<SectorRankingsResponseDto, 'snapshotId' | 'asOf' | 'source'> {
|
||||
@@ -72,6 +99,32 @@ function statusFor(items: readonly SectorRankingRecord[], rawCount: number): Mar
|
||||
return 'fresh';
|
||||
}
|
||||
|
||||
function sortFlows(
|
||||
items: readonly CapitalFlowRecord[],
|
||||
sort: CapitalFlowQuery['sort'],
|
||||
): CapitalFlowRecord[] {
|
||||
return [...items].sort((left, right) => {
|
||||
const leftValue = left.netInflow ?? Number.NEGATIVE_INFINITY;
|
||||
const rightValue = right.netInflow ?? Number.NEGATIVE_INFINITY;
|
||||
const difference = sort === 'inflow' ? rightValue - leftValue : leftValue - rightValue;
|
||||
return difference || left.name.localeCompare(right.name, 'zh-CN');
|
||||
});
|
||||
}
|
||||
|
||||
function flowStatusFor(
|
||||
items: readonly CapitalFlowRecord[],
|
||||
rawCount: number,
|
||||
): MarketOverviewStatus {
|
||||
if (!items.length) return 'unavailable';
|
||||
if (
|
||||
items.length < rawCount ||
|
||||
items.some((item) => item.flowDefinition === 'unavailable' || item.tradeDate === null)
|
||||
) {
|
||||
return 'partial';
|
||||
}
|
||||
return 'fresh';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MarketStructureService {
|
||||
constructor(
|
||||
@@ -114,6 +167,38 @@ export class MarketStructureService {
|
||||
}
|
||||
}
|
||||
|
||||
async getCapitalFlow(query: CapitalFlowQuery): Promise<CapitalFlowResponseDto> {
|
||||
const meta = snapshotMeta('aktools');
|
||||
try {
|
||||
const rows = await this.source.getCapitalFlowRows(query.category);
|
||||
const items = rows.map((row) => mapCapitalFlow(row, query.category, this.readTradeDate(row)));
|
||||
const sortedAll = sortFlows(items, query.sort);
|
||||
const sorted = sortedAll.slice(0, query.limit);
|
||||
const status = flowStatusFor(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;
|
||||
|
||||
Reference in New Issue
Block a user