feat(FEAT-MO-005): 增加行业题材资金流向接口

This commit is contained in:
2026-08-25 01:56:51 +08:00
parent 8174f3f698
commit 7b76dd1353
6 changed files with 379 additions and 6 deletions
@@ -1,8 +1,12 @@
import { BadRequestException, Controller, Get, Query } from '@nestjs/common'; import { BadRequestException, Controller, Get, Query } from '@nestjs/common';
import { ApiOkResponse, ApiQuery, ApiTags } from '@nestjs/swagger'; import { ApiOkResponse, ApiQuery, ApiTags } from '@nestjs/swagger';
import { SectorRankingsResponseDto } from './market-structure.dto'; import { CapitalFlowResponseDto, SectorRankingsResponseDto } from './market-structure.dto';
import { MarketStructureService, parseSectorRankingQuery } from './market-structure.service'; import {
MarketStructureService,
parseCapitalFlowQuery,
parseSectorRankingQuery,
} from './market-structure.service';
@ApiTags('market-overview-structure') @ApiTags('market-overview-structure')
@Controller('market-overview') @Controller('market-overview')
@@ -25,4 +29,21 @@ export class MarketStructureController {
throw new BadRequestException(error instanceof Error ? error.message : '排行参数无效'); 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 }) @ApiProperty({ type: () => SectorRankingsDataDto, nullable: true })
data!: SectorRankingsDataDto | null; 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 { 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'; import type { MarketStructureSourceClient } from './market-structure.types';
const source: MarketStructureSourceClient = { const source: MarketStructureSourceClient = {
@@ -8,7 +12,10 @@ const source: MarketStructureSourceClient = {
{ 代码: 'BK2', 名称: '弱势', 涨跌幅: '-2', 成交额: '200000000', 日期: '20260825' }, { 代码: 'BK2', 名称: '弱势', 涨跌幅: '-2', 成交额: '200000000', 日期: '20260825' },
{ 代码: 'BK1', 名称: '强势', 涨跌幅: '5', 成交额: '100000000', 日期: '20260825' }, { 代码: 'BK1', 名称: '强势', 涨跌幅: '5', 成交额: '100000000', 日期: '20260825' },
], ],
getCapitalFlowRows: async () => [], getCapitalFlowRows: async () => [
{ 代码: 'BK1', 名称: '强势', '今日主力净流入-净额': '200000000', 日期: '20260825' },
{ 代码: 'BK2', 名称: '弱势', '今日主力净流入-净额': '-100000000', 日期: '20260825' },
],
getMarketFundFlowRows: async () => [], getMarketFundFlowRows: async () => [],
}; };
@@ -53,4 +60,47 @@ describe('行业题材排行服务', () => {
expect(response).toMatchObject({ status: 'unavailable', data: null }); expect(response).toMatchObject({ status: 'unavailable', data: null });
expect(response.warnings).toEqual(['行业排行数据不可用']); 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 { Inject, Injectable } from '@nestjs/common';
import type { MarketOverviewStatus } from '../market-overview.dto'; import type { MarketOverviewStatus } from '../market-overview.dto';
import { mapSectorRanking } from './market-structure.mapper'; import { mapCapitalFlow, mapSectorRanking } from './market-structure.mapper';
import type { import type {
CapitalFlowRecord,
SectorRankingRecord, SectorRankingRecord,
StructureCategory, StructureCategory,
StructureSort, StructureSort,
MarketStructureSourceClient, MarketStructureSourceClient,
} from './market-structure.types'; } 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'); export const MARKET_STRUCTURE_SOURCE_PORT = Symbol('MARKET_STRUCTURE_SOURCE_PORT');
@@ -18,8 +19,15 @@ export interface SectorRankingQuery {
limit: number; limit: number;
} }
export interface CapitalFlowQuery {
category: StructureCategory;
sort: Extract<StructureSort, 'inflow' | 'outflow'>;
limit: number;
}
const CATEGORIES = new Set<StructureCategory>(['industry', 'concept', 'board']); const CATEGORIES = new Set<StructureCategory>(['industry', 'concept', 'board']);
const SORTS = new Set<SectorRankingQuery['sort']>(['rise', 'fall', 'turnover']); const SORTS = new Set<SectorRankingQuery['sort']>(['rise', 'fall', 'turnover']);
const FLOW_SORTS = new Set<CapitalFlowQuery['sort']>(['inflow', 'outflow']);
export function parseSectorRankingQuery( export function parseSectorRankingQuery(
category = 'industry', 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( function snapshotMeta(
source: 'aktools', source: 'aktools',
): Pick<SectorRankingsResponseDto, 'snapshotId' | 'asOf' | 'source'> { ): Pick<SectorRankingsResponseDto, 'snapshotId' | 'asOf' | 'source'> {
@@ -72,6 +99,32 @@ function statusFor(items: readonly SectorRankingRecord[], rawCount: number): Mar
return 'fresh'; 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() @Injectable()
export class MarketStructureService { export class MarketStructureService {
constructor( 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 { private readTradeDate(row: Readonly<Record<string, unknown>>): string | null {
const value = row['日期'] ?? row['交易日期'] ?? row['tradeDate']; const value = row['日期'] ?? row['交易日期'] ?? row['tradeDate'];
return typeof value === 'string' && /^\d{8}$/.test(value) ? value : null; return typeof value === 'string' && /^\d{8}$/.test(value) ? value : null;
+104
View File
@@ -29,6 +29,69 @@
"required": ["snapshotId", "asOf", "source", "status", "warnings", "data"], "required": ["snapshotId", "asOf", "source", "status", "warnings", "data"],
"type": "object" "type": "object"
}, },
"CapitalFlowDataDto": {
"properties": {
"category": {
"enum": ["industry", "concept", "board"],
"type": "string"
},
"items": {
"items": { "$ref": "#/components/schemas/CapitalFlowItemDto" },
"type": "array"
},
"sort": { "enum": ["inflow", "outflow"], "type": "string" }
},
"required": ["items", "sort", "category"],
"type": "object"
},
"CapitalFlowItemDto": {
"properties": {
"category": {
"enum": ["industry", "concept", "board"],
"type": "string"
},
"code": { "nullable": true, "type": "string" },
"flowDefinition": { "type": "string" },
"name": { "type": "string" },
"netInflow": {
"description": "亿元",
"nullable": true,
"type": "number"
},
"netOutflow": {
"description": "亿元",
"nullable": true,
"type": "number"
},
"tradeDate": { "nullable": true, "type": "string" },
"turnoverAmount": {
"description": "亿元",
"nullable": true,
"type": "number"
}
},
"required": ["category", "name", "flowDefinition"],
"type": "object"
},
"CapitalFlowResponseDto": {
"properties": {
"asOf": { "format": "date-time", "type": "string" },
"data": {
"allOf": [{ "$ref": "#/components/schemas/CapitalFlowDataDto" }],
"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"
},
"DependencyHealthDto": { "DependencyHealthDto": {
"properties": { "properties": {
"detail": { "type": "string" }, "detail": { "type": "string" },
@@ -535,6 +598,47 @@
"tags": ["market-overview"] "tags": ["market-overview"]
} }
}, },
"/api/v1/market-overview/capital-flow": {
"get": {
"operationId": "MarketStructureController_getCapitalFlow",
"parameters": [
{
"in": "query",
"name": "limit",
"required": false,
"schema": { "maximum": 100, "minimum": 1, "type": "number" }
},
{
"in": "query",
"name": "sort",
"required": false,
"schema": { "enum": ["inflow", "outflow"], "type": "string" }
},
{
"in": "query",
"name": "category",
"required": false,
"schema": {
"enum": ["industry", "concept", "board"],
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CapitalFlowResponseDto"
}
}
},
"description": ""
}
},
"tags": ["market-overview-structure"]
}
},
"/api/v1/market-overview/funds": { "/api/v1/market-overview/funds": {
"get": { "get": {
"operationId": "MarketOverviewController_getFunds", "operationId": "MarketOverviewController_getFunds",
+71
View File
@@ -36,6 +36,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
'/api/v1/market-overview/capital-flow': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations['MarketStructureController_getCapitalFlow'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/market-overview/funds': { '/api/v1/market-overview/funds': {
parameters: { parameters: {
query?: never; query?: never;
@@ -200,6 +216,38 @@ export interface components {
status: 'fresh' | 'delayed' | 'partial' | 'unavailable'; status: 'fresh' | 'delayed' | 'partial' | 'unavailable';
warnings: string[]; warnings: string[];
}; };
CapitalFlowDataDto: {
/** @enum {string} */
category: 'industry' | 'concept' | 'board';
items: components['schemas']['CapitalFlowItemDto'][];
/** @enum {string} */
sort: 'inflow' | 'outflow';
};
CapitalFlowItemDto: {
/** @enum {string} */
category: 'industry' | 'concept' | 'board';
code?: string | null;
flowDefinition: string;
name: string;
/** @description 亿元 */
netInflow?: number | null;
/** @description 亿元 */
netOutflow?: number | null;
tradeDate?: string | null;
/** @description 亿元 */
turnoverAmount?: number | null;
};
CapitalFlowResponseDto: {
/** Format: date-time */
asOf: string;
data: components['schemas']['CapitalFlowDataDto'] | null;
snapshotId: string;
/** @enum {string} */
source: 'tdx' | 'aktools' | 'mixed';
/** @enum {string} */
status: 'fresh' | 'delayed' | 'partial' | 'unavailable';
warnings: string[];
};
DependencyHealthDto: { DependencyHealthDto: {
detail: string; detail: string;
/** @enum {string} */ /** @enum {string} */
@@ -509,6 +557,29 @@ export interface operations {
}; };
}; };
}; };
MarketStructureController_getCapitalFlow: {
parameters: {
query?: {
limit?: number;
sort?: 'inflow' | 'outflow';
category?: 'industry' | 'concept' | 'board';
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['CapitalFlowResponseDto'];
};
};
};
};
MarketOverviewController_getFunds: { MarketOverviewController_getFunds: {
parameters: { parameters: {
query?: never; query?: never;