302 lines
9.8 KiB
TypeScript
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}数据不可用`;
|
|
}
|
|
}
|