import { Inject, Injectable } from '@nestjs/common'; import { mapBrokenBoardPool, mapLimitUpPool, mapPreviousLimitUpPool, type BrokenBoardSummary, type LimitUpSummary, type PreviousLimitUpSummary, } from './aktools/aktools.mapper'; import { AktoolsClientError } from './aktools/aktools.client'; import type { AktoolsDataClient, AktoolsRawRow } from './aktools/aktools.types'; import { type ShortTermConclusionDataDto, type ShortTermConclusionResponseDto, type ShortTermEmotionDataDto, type ShortTermEmotionResponseDto, } from './short-term.dto'; export const AKTOOLS_CLIENT_PORT = Symbol('AKTOOLS_CLIENT_PORT'); function getShanghaiDate(date: Date): string { const parts = new Intl.DateTimeFormat('en-US', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', }).formatToParts(date); const values = Object.fromEntries(parts.map((part) => [part.type, part.value])); return `${values.year}${values.month}${values.day}`; } function createMeta(tradeDate: string) { const asOf = new Date().toISOString(); return { snapshotId: `${asOf.slice(0, 10).replaceAll('-', '')}-${asOf.slice(11, 19).replaceAll(':', '')}`, asOf, source: 'akshare' as const, tradeDate, }; } function warningFor(error: unknown, subject: string): string { if (error instanceof AktoolsClientError && error.kind === 'timeout') { return `${subject}请求超时`; } if (error instanceof AktoolsClientError && error.kind === 'disabled') { return 'AKTools 未启用'; } return `${subject}数据不可用`; } function toEmotionData( tradeDate: string, limitUp: LimitUpSummary | null, brokenBoard: BrokenBoardSummary | null, previousLimitUp: PreviousLimitUpSummary | null, ): ShortTermEmotionDataDto { return { tradeDate, limitUpCount: limitUp?.count ?? null, maxBoard: limitUp?.maxBoard ?? null, tiers: limitUp?.tiers ?? [], brokenBoardCount: brokenBoard?.count ?? null, previousAverageChangePercent: previousLimitUp?.averageChangePercent ?? null, previousUpCount: previousLimitUp?.upCount ?? null, previousDownCount: previousLimitUp?.downCount ?? null, previousFlatCount: previousLimitUp?.flatCount ?? null, sealFundsAmount: limitUp?.sealFundsAmount ?? null, industries: limitUp?.industries ?? [], }; } function createConclusion(data: ShortTermEmotionDataDto): ShortTermConclusionDataDto { const strong = (data.maxBoard ?? 0) >= 4 && (data.previousAverageChangePercent ?? Number.NEGATIVE_INFINITY) > 0 && (data.brokenBoardCount ?? 0) <= (data.limitUpCount ?? 0); const weak = (data.previousAverageChangePercent ?? 0) < 0 || (data.brokenBoardCount ?? 0) > (data.limitUpCount ?? 0); const verdict = strong ? '超短情绪偏强' : weak ? '超短情绪偏弱' : '超短情绪分化'; return { verdict, drivers: [ data.limitUpCount === null ? '涨停池数据缺失' : `涨停 ${data.limitUpCount} 家`, data.maxBoard === null ? '最高连板数据缺失' : `最高连板 ${data.maxBoard} 板`, data.previousAverageChangePercent === null ? '昨日涨停表现缺失' : `昨日涨停股平均涨跌幅 ${data.previousAverageChangePercent}%`, ], risks: [ data.brokenBoardCount === null ? '炸板池数据缺失' : `炸板 ${data.brokenBoardCount} 家`, '结论仅为规则统计,不构成交易建议', ], }; } @Injectable() export class ShortTermMarketService { constructor( @Inject(AKTOOLS_CLIENT_PORT) private readonly aktoolsClient: AktoolsDataClient, ) {} async getEmotion(): Promise { const tradeDate = getShanghaiDate(new Date()); const meta = createMeta(tradeDate); const [limitUp, brokenBoard, previousLimitUp] = await Promise.allSettled([ this.aktoolsClient.getLimitUpPool(tradeDate), this.aktoolsClient.getBrokenBoardPool(tradeDate), this.aktoolsClient.getPreviousLimitUpPool(tradeDate), ]); const limitUpData = this.readResult(limitUp, mapLimitUpPool); const brokenBoardData = this.readResult(brokenBoard, mapBrokenBoardPool); const previousLimitUpData = this.readResult(previousLimitUp, mapPreviousLimitUpPool); const failures = [ [limitUp, '涨停池'], [brokenBoard, '炸板池'], [previousLimitUp, '昨日涨停池'], ] as const; const warnings = failures.flatMap(([result, subject]) => result.status === 'rejected' ? [warningFor(result.reason, subject)] : [], ); const data = limitUpData || brokenBoardData || previousLimitUpData ? toEmotionData(tradeDate, limitUpData, brokenBoardData, previousLimitUpData) : null; return { ...meta, status: data === null ? 'unavailable' : warnings.length ? 'partial' : 'fresh', data, warnings, }; } async getConclusion(): Promise { const emotion = await this.getEmotion(); return { ...emotion, data: emotion.data ? createConclusion(emotion.data) : null, }; } private readResult( result: PromiseSettledResult, mapper: (rows: readonly AktoolsRawRow[]) => T, ): T | null { return result.status === 'fulfilled' ? mapper(result.value) : null; } }