import { Inject, Injectable } from '@nestjs/common'; import type { BreadthDataDto, FundDataDto, IndicesDataDto, SectorDataDto, SummaryDataDto, } from './market-overview.dto'; import { CORE_INDEXES } from './tdx/tdx.types'; import { mapBreadthSnapshots, mapIndexQuotes, mapSectorQuotes, mapTurnoverTrend, } from './tdx/tdx.mapper'; import { TdxClientError, type TdxClient } from './tdx/tdx.client'; import type { TdxMarketDataResult, TdxMarketSnapshotResult, TdxStockListResult, } from './tdx/tdx.types'; import { BREADTH_INDEXES, TURNOVER_INDEXES } from './tdx/tdx.types'; export const TDX_CLIENT_PORT = Symbol('TDX_CLIENT_PORT'); export type MarketOverviewResponse = { snapshotId: string; asOf: string; source: 'tdx'; status: 'fresh' | 'delayed' | 'partial' | 'unavailable'; data: TData | null; warnings: string[]; }; function createSnapshotMeta(): Pick< MarketOverviewResponse, 'snapshotId' | 'asOf' | 'source' > { const asOf = new Date().toISOString(); const snapshotId = `${asOf.slice(0, 10).replaceAll('-', '')}-${asOf.slice(11, 19).replaceAll(':', '')}`; return { snapshotId, asOf, source: 'tdx' }; } /** 交易日状态统一按上海日历判断;休市日保守标记 delayed,避免把历史值冒充实时。 */ function getShanghaiCalendarDate(asOf: string): string { const parts = new Intl.DateTimeFormat('en-US', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', }).formatToParts(new Date(asOf)); const values = Object.fromEntries(parts.map((part) => [part.type, part.value])); return `${values.year}${values.month}${values.day}`; } function createTradeDateWarning(subject: string, tradeDate: string | undefined, asOf: string) { if (!tradeDate) return `${subject}交易日期无法确认`; return tradeDate === getShanghaiCalendarDate(asOf) ? null : `${subject}交易日期为 ${tradeDate},非当前日期`; } @Injectable() export class MarketOverviewService { constructor(@Inject(TDX_CLIENT_PORT) private readonly tdxClient: TdxClient) {} async getIndices(): Promise> { const meta = createSnapshotMeta(); try { const result = await this.tdxClient.request('get_market_data', { stock_list: CORE_INDEXES.map((item) => item.code), count: 5, dividend_type: 'none', period: '1d', }); const items = mapIndexQuotes(result, CORE_INDEXES); const tradeDateWarning = createTradeDateWarning('核心指数', items[0]?.tradeDate, meta.asOf); const status = items.length !== CORE_INDEXES.length ? 'partial' : tradeDateWarning ? 'delayed' : 'fresh'; return { ...meta, status, data: { items }, warnings: status === 'partial' ? ['部分核心指数未返回有效数据'] : tradeDateWarning ? [tradeDateWarning] : [], }; } catch (error) { return { ...meta, status: 'unavailable', data: null, warnings: [this.toWarning(error, '核心指数')], }; } } async getSummary(): Promise> { const indices = await this.getIndices(); if (indices.status === 'unavailable' || !indices.data) { return { ...indices, data: null }; } const changes = indices.data.items .map((item) => item.changePercent) .filter((value): value is number => value !== null); const averageChange = changes.length ? Math.round((changes.reduce((sum, value) => sum + value, 0) / changes.length) * 100) / 100 : null; const marketStatus = averageChange === null ? '指数数据不完整' : averageChange >= 0 ? '指数偏强' : '指数偏弱'; const summary: SummaryDataDto = { verdict: averageChange === null ? '市场状态待确认' : averageChange >= 0 ? '市场维持偏强震荡' : '市场维持偏弱震荡', marketStatus, volumeStatus: '成交额趋势见下方', sentimentStatus: '涨跌家数见下方', drivers: changes.length ? [`核心指数平均涨跌幅 ${averageChange}%`] : ['核心指数数据不足'], risks: ['主力净流入和盘中新闻仍待补充数据源验证'], }; return { ...indices, data: summary }; } async getBreadth(): Promise> { const meta = createSnapshotMeta(); const [results, [referenceResult]] = await Promise.all([ Promise.allSettled( BREADTH_INDEXES.map((item) => this.tdxClient.request('get_market_snapshot', { stock_code: item.code, }), ), ), Promise.allSettled([ this.tdxClient.request('get_market_data', { stock_list: [CORE_INDEXES[0].code], count: 1, dividend_type: 'none', period: '1d', }), ]), ]); const validSnapshots = results.flatMap((result) => { if (result.status === 'rejected') return []; return mapBreadthSnapshots([result.value]).validCount === 1 ? [result.value] : []; }); const mapped = mapBreadthSnapshots(validSnapshots); const unavailableMarkets = BREADTH_INDEXES.filter((_, index) => { const result = results[index]; return ( !result || result.status === 'rejected' || mapBreadthSnapshots([result.value]).validCount === 0 ); }).map((item) => item.name); if (!mapped.data) { return { ...meta, status: 'unavailable', data: null, warnings: [`市场宽度数据不可用:${unavailableMarkets.join('、')}`], }; } const referenceTradeDate = referenceResult?.status === 'fulfilled' ? mapIndexQuotes(referenceResult.value, [CORE_INDEXES[0]])[0]?.tradeDate : undefined; const tradeDateWarning = createTradeDateWarning('市场宽度', referenceTradeDate, meta.asOf); const status = mapped.validCount !== BREADTH_INDEXES.length ? 'partial' : tradeDateWarning ? 'delayed' : 'fresh'; return { ...meta, status, data: mapped.data, warnings: status === 'partial' ? [`部分市场涨跌家数不可用:${unavailableMarkets.join('、')}`] : tradeDateWarning ? [tradeDateWarning] : [], }; } async getSectors(): Promise> { const meta = createSnapshotMeta(); try { const catalog = await this.tdxClient.request('get_stock_list', { market: '16', list_type: 1, }); const definitions = catalog.Value.map(({ Code: code, Name: name }) => ({ code, name })); if (!definitions.length) { return { ...meta, status: 'unavailable', data: null, warnings: ['一级行业目录为空'], }; } const result = await this.tdxClient.request('get_market_data', { stock_list: definitions.map((item) => item.code), count: 2, dividend_type: 'none', period: '1d', }); const mappedItems = mapSectorQuotes(result, definitions); const items = mappedItems.slice(0, 8); const tradeDateWarning = createTradeDateWarning( '一级行业', mappedItems[0]?.tradeDate, meta.asOf, ); const status = mappedItems.length === 0 ? 'unavailable' : mappedItems.length !== definitions.length ? 'partial' : tradeDateWarning ? 'delayed' : 'fresh'; return { ...meta, status, data: items.length ? { items } : null, warnings: status === 'fresh' ? [] : status === 'partial' ? ['部分一级行业未返回有效行情'] : status === 'delayed' && tradeDateWarning ? [tradeDateWarning] : ['一级行业行情不可用'], }; } catch (error) { return { ...meta, status: 'unavailable', data: null, warnings: [this.toWarning(error, '板块行情')], }; } } async getFunds(): Promise> { const meta = createSnapshotMeta(); try { const result = await this.tdxClient.request('get_market_data', { stock_list: TURNOVER_INDEXES.map((item) => item.code), count: 2, dividend_type: 'none', period: '1d', }); const mapped = mapTurnoverTrend(result, TURNOVER_INDEXES); if (!mapped.data) return { ...meta, status: 'unavailable', data: null, warnings: ['成交额趋势数据不完整'] }; const tradeDateWarning = createTradeDateWarning('成交额', mapped.data.tradeDate, meta.asOf); const status = mapped.validMarketCount !== TURNOVER_INDEXES.length ? 'partial' : tradeDateWarning ? 'delayed' : 'fresh'; return { ...meta, status, data: mapped.data, warnings: status === 'partial' ? [`部分市场成交额不可用,当前汇总 ${mapped.validMarketCount} 个市场`] : tradeDateWarning ? [tradeDateWarning] : [], }; } catch (error) { return { ...meta, status: 'unavailable', data: null, warnings: [this.toWarning(error, '成交额趋势')], }; } } private toWarning(error: unknown, subject: string): string { if (error instanceof TdxClientError && error.kind === 'timeout') return `${subject}请求超时`; if (error instanceof TdxClientError && error.kind === 'network') return `${subject}网络不可用`; return `${subject}数据不可用`; } }