feat(FEAT-MO-005): 增加全市场成交额趋势接口

This commit is contained in:
2026-08-25 02:04:00 +08:00
parent 7b76dd1353
commit ad40ffffc4
6 changed files with 381 additions and 3 deletions
@@ -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<TurnoverTrendResponseDto> {
try {
return this.service.getTurnoverTrend(parseTurnoverTrendQuery(interval, tradeDate));
} catch (error) {
throw new BadRequestException(error instanceof Error ? error.message : '成交额趋势参数无效');
}
}
}
@@ -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;
}
@@ -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 <TResponse>() =>
({
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 <TResponse>() => ({ ErrorId: '0', Value: {} }) as TResponse,
};
const response = await new MarketStructureService(source, tdxClient).getTurnoverTrend({
interval: '5',
tradeDate: '20260824',
});
expect(response).toMatchObject({ status: 'unavailable', data: null });
});
});
@@ -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<StructureCategory>(['industry', 'concept', 'board']);
const SORTS = new Set<SectorRankingQuery['sort']>(['rise', 'fall', 'turnover']);
const FLOW_SORTS = new Set<CapitalFlowQuery['sort']>(['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<SectorRankingsResponseDto, 'snapshotId' | 'asOf' | 'source'> {
@@ -125,11 +168,50 @@ function flowStatusFor(
return 'fresh';
}
function aggregateTdxSeries(
result: TdxMarketDataResult,
tradeDate: string,
): { rows: Array<{ 时间: string; 成交额: number }>; marketCount: number } {
const amountsByTime = new Map<string, number>();
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 <TResponse>() => ({}) as TResponse,
},
) {}
async getSectorRankings(query: SectorRankingQuery): Promise<SectorRankingsResponseDto> {
@@ -199,6 +281,53 @@ export class MarketStructureService {
}
}
async getTurnoverTrend(query: TurnoverTrendQuery): Promise<TurnoverTrendResponseDto> {
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<TdxMarketDataResult>('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<Record<string, unknown>>): string | null {
const value = row['日期'] ?? row['交易日期'] ?? row['tradeDate'];
return typeof value === 'string' && /^\d{8}$/.test(value) ? value : null;