151 lines
5.2 KiB
TypeScript
151 lines
5.2 KiB
TypeScript
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;
|
|
}
|
|
}
|