Files
a-share-analysis/server/apps/api/src/market-overview/market-overview.service.ts
T

302 lines
9.8 KiB
TypeScript

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<TData> = {
snapshotId: string;
asOf: string;
source: 'tdx';
status: 'fresh' | 'delayed' | 'partial' | 'unavailable';
data: TData | null;
warnings: string[];
};
function createSnapshotMeta(): Pick<
MarketOverviewResponse<never>,
'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<MarketOverviewResponse<IndicesDataDto>> {
const meta = createSnapshotMeta();
try {
const result = await this.tdxClient.request<TdxMarketDataResult>('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<MarketOverviewResponse<SummaryDataDto>> {
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<MarketOverviewResponse<BreadthDataDto>> {
const meta = createSnapshotMeta();
const [results, [referenceResult]] = await Promise.all([
Promise.allSettled(
BREADTH_INDEXES.map((item) =>
this.tdxClient.request<TdxMarketSnapshotResult>('get_market_snapshot', {
stock_code: item.code,
}),
),
),
Promise.allSettled([
this.tdxClient.request<TdxMarketDataResult>('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<MarketOverviewResponse<SectorDataDto>> {
const meta = createSnapshotMeta();
try {
const catalog = await this.tdxClient.request<TdxStockListResult>('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<TdxMarketDataResult>('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<MarketOverviewResponse<FundDataDto>> {
const meta = createSnapshotMeta();
try {
const result = await this.tdxClient.request<TdxMarketDataResult>('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}数据不可用`;
}
}