From 7b76dd1353badb2047e8f6124b1dc853c8954d37 Mon Sep 17 00:00:00 2001 From: Citrons <909229731@qq.com> Date: Tue, 25 Aug 2026 01:56:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(FEAT-MO-005):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E8=A1=8C=E4=B8=9A=E9=A2=98=E6=9D=90=E8=B5=84=E9=87=91=E6=B5=81?= =?UTF-8?q?=E5=90=91=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../structure/market-structure.controller.ts | 25 ++++- .../structure/market-structure.dto.ts | 42 +++++++ .../market-structure.service.test.ts | 54 ++++++++- .../structure/market-structure.service.ts | 89 ++++++++++++++- server/openapi/openapi.json | 104 ++++++++++++++++++ web/src/api/generated/schema.ts | 71 ++++++++++++ 6 files changed, 379 insertions(+), 6 deletions(-) diff --git a/server/apps/api/src/market-overview/structure/market-structure.controller.ts b/server/apps/api/src/market-overview/structure/market-structure.controller.ts index 73376fb..c151e77 100644 --- a/server/apps/api/src/market-overview/structure/market-structure.controller.ts +++ b/server/apps/api/src/market-overview/structure/market-structure.controller.ts @@ -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 { + try { + return this.service.getCapitalFlow(parseCapitalFlowQuery(category, sort, limit)); + } catch (error) { + throw new BadRequestException(error instanceof Error ? error.message : '资金流参数无效'); + } + } } diff --git a/server/apps/api/src/market-overview/structure/market-structure.dto.ts b/server/apps/api/src/market-overview/structure/market-structure.dto.ts index 1e21a71..cc782ce 100644 --- a/server/apps/api/src/market-overview/structure/market-structure.dto.ts +++ b/server/apps/api/src/market-overview/structure/market-structure.dto.ts @@ -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; + + @ApiProperty({ enum: ['industry', 'concept', 'board'] }) + category!: StructureCategory; +} + +export class CapitalFlowResponseDto extends MarketOverviewMetaDto { + @ApiProperty({ type: () => CapitalFlowDataDto, nullable: true }) + data!: CapitalFlowDataDto | null; +} diff --git a/server/apps/api/src/market-overview/structure/market-structure.service.test.ts b/server/apps/api/src/market-overview/structure/market-structure.service.test.ts index 5f68f5a..8022d28 100644 --- a/server/apps/api/src/market-overview/structure/market-structure.service.test.ts +++ b/server/apps/api/src/market-overview/structure/market-structure.service.test.ts @@ -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', + }); + }); }); diff --git a/server/apps/api/src/market-overview/structure/market-structure.service.ts b/server/apps/api/src/market-overview/structure/market-structure.service.ts index dd0e9cb..5d0184e 100644 --- a/server/apps/api/src/market-overview/structure/market-structure.service.ts +++ b/server/apps/api/src/market-overview/structure/market-structure.service.ts @@ -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; + limit: number; +} + const CATEGORIES = new Set(['industry', 'concept', 'board']); const SORTS = new Set(['rise', 'fall', 'turnover']); +const FLOW_SORTS = new Set(['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 { @@ -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 { + 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>): string | null { const value = row['日期'] ?? row['交易日期'] ?? row['tradeDate']; return typeof value === 'string' && /^\d{8}$/.test(value) ? value : null; diff --git a/server/openapi/openapi.json b/server/openapi/openapi.json index bf0e2cd..73a64d1 100644 --- a/server/openapi/openapi.json +++ b/server/openapi/openapi.json @@ -29,6 +29,69 @@ "required": ["snapshotId", "asOf", "source", "status", "warnings", "data"], "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": { "properties": { "detail": { "type": "string" }, @@ -535,6 +598,47 @@ "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": { "get": { "operationId": "MarketOverviewController_getFunds", diff --git a/web/src/api/generated/schema.ts b/web/src/api/generated/schema.ts index e8fec08..1f20455 100644 --- a/web/src/api/generated/schema.ts +++ b/web/src/api/generated/schema.ts @@ -36,6 +36,22 @@ export interface paths { patch?: 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': { parameters: { query?: never; @@ -200,6 +216,38 @@ export interface components { status: 'fresh' | 'delayed' | 'partial' | 'unavailable'; 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: { detail: 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: { parameters: { query?: never;