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 c151e77..42ed125 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,11 +1,16 @@ import { BadRequestException, Controller, Get, Query } from '@nestjs/common'; import { ApiOkResponse, ApiQuery, ApiTags } from '@nestjs/swagger'; -import { CapitalFlowResponseDto, SectorRankingsResponseDto } from './market-structure.dto'; +import { + CapitalFlowResponseDto, + SectorRankingsResponseDto, + TurnoverTrendResponseDto, +} from './market-structure.dto'; import { MarketStructureService, parseCapitalFlowQuery, parseSectorRankingQuery, + parseTurnoverTrendQuery, } from './market-structure.service'; @ApiTags('market-overview-structure') @@ -46,4 +51,19 @@ export class MarketStructureController { throw new BadRequestException(error instanceof Error ? error.message : '资金流参数无效'); } } + + @Get('turnover-trend') + @ApiQuery({ name: 'interval', required: false, enum: ['5', '15'] }) + @ApiQuery({ name: 'tradeDate', required: false, type: String, pattern: '^\\d{8}$' }) + @ApiOkResponse({ type: TurnoverTrendResponseDto }) + getTurnoverTrend( + @Query('interval') interval?: string, + @Query('tradeDate') tradeDate?: string, + ): Promise { + try { + return this.service.getTurnoverTrend(parseTurnoverTrendQuery(interval, tradeDate)); + } 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 cc782ce..90e6701 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 @@ -89,3 +89,36 @@ export class CapitalFlowResponseDto extends MarketOverviewMetaDto { @ApiProperty({ type: () => CapitalFlowDataDto, nullable: true }) data!: CapitalFlowDataDto | null; } + +export class TurnoverPointDto { + @ApiProperty({ type: String, format: 'date-time' }) + time!: string; + + @ApiProperty({ type: Number, description: '该时间区间成交额,单位亿元' }) + amount!: number; + + @ApiProperty({ type: Number, description: '当日累计成交额,单位亿元' }) + cumulativeAmount!: number; +} + +export class TurnoverTrendDataDto { + @ApiProperty({ type: [TurnoverPointDto] }) + points!: TurnoverPointDto[]; + + @ApiProperty({ enum: ['5', '15'] }) + granularity!: '5' | '15'; + + @ApiProperty({ type: String, example: '亿元' }) + unit!: '亿元'; + + @ApiProperty({ type: Number, minimum: 1, maximum: 3 }) + marketCount!: number; + + @ApiProperty({ type: Number }) + invalidPointCount!: number; +} + +export class TurnoverTrendResponseDto extends MarketOverviewMetaDto { + @ApiProperty({ type: () => TurnoverTrendDataDto, nullable: true }) + data!: TurnoverTrendDataDto | 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 8022d28..9a91d91 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 @@ -4,8 +4,10 @@ import { MarketStructureService, parseCapitalFlowQuery, parseSectorRankingQuery, + parseTurnoverTrendQuery, } from './market-structure.service'; import type { MarketStructureSourceClient } from './market-structure.types'; +import type { TdxClient } from '../tdx/tdx.client'; const source: MarketStructureSourceClient = { getSectorRows: async () => [ @@ -103,4 +105,52 @@ describe('行业题材排行服务', () => { flowDefinition: 'unavailable', }); }); + + it('校验成交额趋势粒度和交易日期', () => { + expect(parseTurnoverTrendQuery('15', '20260824')).toEqual({ + interval: '15', + tradeDate: '20260824', + }); + expect(() => parseTurnoverTrendQuery('1', '20260824')).toThrow('粒度参数无效'); + expect(() => parseTurnoverTrendQuery('5', '20260231')).toThrow('交易日期参数无效'); + }); + + it('聚合多个市场的 TDX 分时成交额并计算累计值', async () => { + const tdxClient: TdxClient = { + request: async () => + ({ + ErrorId: '0', + Value: { + '000001.SH': { + Date: ['2026-08-24 09:30:00', '2026-08-24 09:35:00'], + Amount: ['10000', '20000'], + }, + '399001.SZ': { + Date: ['2026-08-24 09:30:00', '2026-08-24 09:35:00'], + Amount: ['30000', '40000'], + }, + }, + }) as TResponse, + }; + const response = await new MarketStructureService(source, tdxClient).getTurnoverTrend({ + interval: '5', + tradeDate: '20260824', + }); + expect(response.status).toBe('partial'); + expect(response.data?.points).toEqual([ + { time: '2026-08-24 09:30:00', amount: 4, cumulativeAmount: 4 }, + { time: '2026-08-24 09:35:00', amount: 6, cumulativeAmount: 10 }, + ]); + }); + + it('TDX 分时序列为空时返回不可用', async () => { + const tdxClient: TdxClient = { + request: async () => ({ ErrorId: '0', Value: {} }) as TResponse, + }; + const response = await new MarketStructureService(source, tdxClient).getTurnoverTrend({ + interval: '5', + tradeDate: '20260824', + }); + expect(response).toMatchObject({ status: 'unavailable', data: null }); + }); }); 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 5d0184e..8b9e8b1 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,7 +1,11 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, Optional } from '@nestjs/common'; import type { MarketOverviewStatus } from '../market-overview.dto'; +import { TDX_CLIENT_PORT } from '../market-overview.service'; +import type { TdxClient } from '../tdx/tdx.client'; +import { TURNOVER_INDEXES, type TdxKlineSeries, type TdxMarketDataResult } from '../tdx/tdx.types'; import { mapCapitalFlow, mapSectorRanking } from './market-structure.mapper'; +import { mapTurnoverPoints } from './market-structure.mapper'; import type { CapitalFlowRecord, SectorRankingRecord, @@ -9,7 +13,11 @@ import type { StructureSort, MarketStructureSourceClient, } from './market-structure.types'; -import type { CapitalFlowResponseDto, SectorRankingsResponseDto } from './market-structure.dto'; +import type { + CapitalFlowResponseDto, + SectorRankingsResponseDto, + TurnoverTrendResponseDto, +} from './market-structure.dto'; export const MARKET_STRUCTURE_SOURCE_PORT = Symbol('MARKET_STRUCTURE_SOURCE_PORT'); @@ -25,6 +33,11 @@ export interface CapitalFlowQuery { limit: number; } +export interface TurnoverTrendQuery { + interval: '5' | '15'; + tradeDate: string; +} + const CATEGORIES = new Set(['industry', 'concept', 'board']); const SORTS = new Set(['rise', 'fall', 'turnover']); const FLOW_SORTS = new Set(['inflow', 'outflow']); @@ -67,6 +80,36 @@ export function parseCapitalFlowQuery( }; } +function isValidDate(value: string): boolean { + if (!/^\d{8}$/.test(value)) return false; + const date = new Date(`${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}T00:00:00Z`); + return ( + date.getUTCFullYear() === Number(value.slice(0, 4)) && + date.getUTCMonth() + 1 === Number(value.slice(4, 6)) && + date.getUTCDate() === Number(value.slice(6, 8)) + ); +} + +export function parseTurnoverTrendQuery( + interval = '5', + tradeDate = currentShanghaiDate(), +): TurnoverTrendQuery { + if (interval !== '5' && interval !== '15') throw new Error('粒度参数无效'); + if (!isValidDate(tradeDate)) throw new Error('交易日期参数无效'); + return { interval, tradeDate }; +} + +function currentShanghaiDate(now = new Date()): string { + const values = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(now); + const parts = Object.fromEntries(values.map((item) => [item.type, item.value])); + return `${parts.year}${parts.month}${parts.day}`; +} + function snapshotMeta( source: 'aktools', ): Pick { @@ -125,11 +168,50 @@ function flowStatusFor( return 'fresh'; } +function aggregateTdxSeries( + result: TdxMarketDataResult, + tradeDate: string, +): { rows: Array<{ 时间: string; 成交额: number }>; marketCount: number } { + const amountsByTime = new Map(); + let marketCount = 0; + for (const definition of TURNOVER_INDEXES) { + const series: TdxKlineSeries | undefined = result.Value?.[definition.code]; + if (!series || !Array.isArray(series.Date) || !Array.isArray(series.Amount)) continue; + if (series.Date.length === 0 || series.Date.length !== series.Amount.length) continue; + const validRows = series.Date.flatMap((time, index) => { + if ( + typeof time !== 'string' || + !time.startsWith( + `${tradeDate.slice(0, 4)}-${tradeDate.slice(4, 6)}-${tradeDate.slice(6, 8)}`, + ) + ) + return []; + const amount = Number(series.Amount[index]); + return Number.isFinite(amount) && amount >= 0 ? [{ time, amount: amount * 10_000 }] : []; + }); + if (!validRows.length) continue; + marketCount += 1; + for (const row of validRows) + amountsByTime.set(row.time, (amountsByTime.get(row.time) ?? 0) + row.amount); + } + return { + rows: [...amountsByTime.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([时间, 成交额]) => ({ 时间, 成交额 })), + marketCount, + }; +} + @Injectable() export class MarketStructureService { constructor( @Inject(MARKET_STRUCTURE_SOURCE_PORT) private readonly source: MarketStructureSourceClient, + @Optional() + @Inject(TDX_CLIENT_PORT) + private readonly tdxClient: TdxClient = { + request: async () => ({}) as TResponse, + }, ) {} async getSectorRankings(query: SectorRankingQuery): Promise { @@ -199,6 +281,53 @@ export class MarketStructureService { } } + async getTurnoverTrend(query: TurnoverTrendQuery): Promise { + const asOf = new Date().toISOString(); + const meta = { + snapshotId: asOf.replace(/[-:TZ.]/g, '').slice(0, 15), + asOf, + source: 'tdx' as const, + }; + try { + const result = await this.tdxClient.request('get_market_data', { + stock_list: TURNOVER_INDEXES.map((item) => item.code), + count: 240, + dividend_type: 'none', + period: `${query.interval}m`, + }); + const aggregated = aggregateTdxSeries(result, query.tradeDate); + const mapped = mapTurnoverPoints(aggregated.rows); + if (!mapped.points.length) { + return { ...meta, status: 'unavailable', data: null, warnings: ['成交额趋势数据不可用'] }; + } + const dateStatus: MarketOverviewStatus = + query.tradeDate === currentShanghaiDate() ? 'fresh' : 'delayed'; + const status: MarketOverviewStatus = + aggregated.marketCount < TURNOVER_INDEXES.length || mapped.invalidCount > 0 + ? 'partial' + : dateStatus; + return { + ...meta, + status, + data: { + points: mapped.points, + granularity: query.interval, + unit: '亿元', + marketCount: aggregated.marketCount, + invalidPointCount: mapped.invalidCount, + }, + warnings: + status === 'fresh' + ? [] + : status === 'partial' + ? ['部分市场或成交额点不可用'] + : [`成交额交易日期为 ${query.tradeDate},非当前日期`], + }; + } 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 73a64d1..e36590e 100644 --- a/server/openapi/openapi.json +++ b/server/openapi/openapi.json @@ -545,6 +545,54 @@ }, "required": ["snapshotId", "asOf", "source", "status", "warnings", "data"], "type": "object" + }, + "TurnoverPointDto": { + "properties": { + "amount": { + "description": "该时间区间成交额,单位亿元", + "type": "number" + }, + "cumulativeAmount": { + "description": "当日累计成交额,单位亿元", + "type": "number" + }, + "time": { "format": "date-time", "type": "string" } + }, + "required": ["time", "amount", "cumulativeAmount"], + "type": "object" + }, + "TurnoverTrendDataDto": { + "properties": { + "granularity": { "enum": ["5", "15"], "type": "string" }, + "invalidPointCount": { "type": "number" }, + "marketCount": { "maximum": 3, "minimum": 1, "type": "number" }, + "points": { + "items": { "$ref": "#/components/schemas/TurnoverPointDto" }, + "type": "array" + }, + "unit": { "example": "亿元", "type": "string" } + }, + "required": ["points", "granularity", "unit", "marketCount", "invalidPointCount"], + "type": "object" + }, + "TurnoverTrendResponseDto": { + "properties": { + "asOf": { "format": "date-time", "type": "string" }, + "data": { + "allOf": [{ "$ref": "#/components/schemas/TurnoverTrendDataDto" }], + "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" } } }, @@ -873,6 +921,38 @@ }, "tags": ["market-overview"] } + }, + "/api/v1/market-overview/turnover-trend": { + "get": { + "operationId": "MarketStructureController_getTurnoverTrend", + "parameters": [ + { + "in": "query", + "name": "tradeDate", + "required": false, + "schema": { "pattern": "^\\d{8}$", "type": "string" } + }, + { + "in": "query", + "name": "interval", + "required": false, + "schema": { "enum": ["5", "15"], "type": "string" } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TurnoverTrendResponseDto" + } + } + }, + "description": "" + } + }, + "tags": ["market-overview-structure"] + } } }, "servers": [], diff --git a/web/src/api/generated/schema.ts b/web/src/api/generated/schema.ts index 1f20455..fd44691 100644 --- a/web/src/api/generated/schema.ts +++ b/web/src/api/generated/schema.ts @@ -196,6 +196,22 @@ export interface paths { patch?: never; trace?: never; }; + '/api/v1/market-overview/turnover-trend': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['MarketStructureController_getTurnoverTrend']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -502,6 +518,34 @@ export interface components { status: 'fresh' | 'delayed' | 'partial' | 'unavailable'; warnings: string[]; }; + TurnoverPointDto: { + /** @description 该时间区间成交额,单位亿元 */ + amount: number; + /** @description 当日累计成交额,单位亿元 */ + cumulativeAmount: number; + /** Format: date-time */ + time: string; + }; + TurnoverTrendDataDto: { + /** @enum {string} */ + granularity: '5' | '15'; + invalidPointCount: number; + marketCount: number; + points: components['schemas']['TurnoverPointDto'][]; + /** @example 亿元 */ + unit: string; + }; + TurnoverTrendResponseDto: { + /** Format: date-time */ + asOf: string; + data: components['schemas']['TurnoverTrendDataDto'] | null; + snapshotId: string; + /** @enum {string} */ + source: 'tdx' | 'aktools' | 'mixed'; + /** @enum {string} */ + status: 'fresh' | 'delayed' | 'partial' | 'unavailable'; + warnings: string[]; + }; }; responses: never; parameters: never; @@ -767,4 +811,26 @@ export interface operations { }; }; }; + MarketStructureController_getTurnoverTrend: { + parameters: { + query?: { + tradeDate?: string; + interval?: '5' | '15'; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['TurnoverTrendResponseDto']; + }; + }; + }; + }; }