feat(FEAT-DS-001): 接入AKTools超短行情补充数据源
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user