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, StructureCategory, StructureSort, MarketStructureSourceClient, } from './market-structure.types'; import type { CapitalFlowResponseDto, SectorRankingsResponseDto, TurnoverTrendResponseDto, } from './market-structure.dto'; export const MARKET_STRUCTURE_SOURCE_PORT = Symbol('MARKET_STRUCTURE_SOURCE_PORT'); export interface SectorRankingQuery { category: StructureCategory; sort: Extract; limit: number; } export interface CapitalFlowQuery { category: StructureCategory; sort: Extract; 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']); export function parseSectorRankingQuery( category = 'industry', sort = 'rise', limit = '10', ): SectorRankingQuery { if (!CATEGORIES.has(category as StructureCategory)) throw new Error('分类参数无效'); if (!SORTS.has(sort as SectorRankingQuery['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 SectorRankingQuery['sort'], limit: parsedLimit, }; } 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 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 { const asOf = new Date().toISOString(); return { snapshotId: asOf.replace(/[-:TZ.]/g, '').slice(0, 15), asOf, source, }; } function sortItems( items: readonly SectorRankingRecord[], sort: SectorRankingQuery['sort'], ): SectorRankingRecord[] { const valueFor = (item: SectorRankingRecord): number => { if (sort === 'turnover') return item.turnoverAmount ?? Number.NEGATIVE_INFINITY; return item.changePercent ?? Number.NEGATIVE_INFINITY; }; const direction = sort === 'fall' ? 1 : -1; return [...items].sort((left, right) => { const difference = (valueFor(left) - valueFor(right)) * direction; return difference || left.name.localeCompare(right.name, 'zh-CN'); }); } function statusFor(items: readonly SectorRankingRecord[], rawCount: number): MarketOverviewStatus { if (!items.length) return 'unavailable'; if (items.length < rawCount || items.some((item) => item.tradeDate === null)) return 'partial'; 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'; } 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 { const meta = snapshotMeta('aktools'); try { const rows = await this.source.getSectorRows(query.category); const items = rows.flatMap((row) => { const mapped = mapSectorRanking(row, query.category, this.readTradeDate(row)); return mapped ? [mapped] : []; }); const sortedAll = sortItems(items, query.sort); const sorted = sortedAll.slice(0, query.limit); const status = statusFor(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: ['行业排行数据不可用'], }; } } 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: ['资金流向数据不可用'], }; } } 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; } }