feat(FEAT-DS-001): 接入AKTools超短行情补充数据源

This commit is contained in:
2026-08-24 21:40:22 +08:00
parent 81c48af8cc
commit 6e8a05114e
42 changed files with 1449 additions and 36 deletions
@@ -0,0 +1,150 @@
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<ShortTermEmotionResponseDto> {
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<ShortTermConclusionResponseDto> {
const emotion = await this.getEmotion();
return {
...emotion,
data: emotion.data ? createConclusion(emotion.data) : null,
};
}
private readResult<T>(
result: PromiseSettledResult<AktoolsRawRow[]>,
mapper: (rows: readonly AktoolsRawRow[]) => T,
): T | null {
return result.status === 'fulfilled' ? mapper(result.value) : null;
}
}