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,60 @@
import { describe, expect, it, vi } from 'vitest';
import { createAktoolsClient, AktoolsClientError } from './aktools.client';
describe('AKTools HTTP 客户端', () => {
it('只请求固定涨停池路径并传递交易日', async () => {
const fetchImpl = vi.fn().mockResolvedValue(
new Response('[{"代码":"000001","连板数":2}]', {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
const client = createAktoolsClient({
enabled: true,
baseUrl: 'http://127.0.0.1:8080',
timeoutMs: 1000,
fetchImpl,
});
await expect(client.getLimitUpPool('20260824')).resolves.toEqual([
{ 代码: '000001', 连板数: 2 },
]);
expect(fetchImpl).toHaveBeenCalledWith(
'http://127.0.0.1:8080/api/public/stock_zt_pool_em?date=20260824',
expect.objectContaining({ method: 'GET' }),
);
});
it('将 HTTP 错误转换为脱敏的客户端错误', async () => {
const fetchImpl = vi.fn().mockResolvedValue(new Response('secret upstream detail', { status: 500 }));
const client = createAktoolsClient({
enabled: true,
baseUrl: 'http://127.0.0.1:8080',
timeoutMs: 1000,
fetchImpl,
});
const promise = client.getBrokenBoardPool('20260824');
await expect(promise).rejects.toBeInstanceOf(AktoolsClientError);
await expect(promise).rejects.toMatchObject({
kind: 'unavailable',
message: 'AKTools 服务不可用',
});
});
it('将请求超时转换为稳定错误', async () => {
const fetchImpl = vi.fn().mockRejectedValue(Object.assign(new Error('aborted'), { name: 'AbortError' }));
const client = createAktoolsClient({
enabled: true,
baseUrl: 'http://127.0.0.1:8080',
timeoutMs: 1000,
fetchImpl,
});
await expect(client.getPreviousLimitUpPool('20260824')).rejects.toMatchObject({
kind: 'timeout',
message: 'AKTools 请求超时',
});
});
});
@@ -0,0 +1,103 @@
import type { AktoolsRuntimeConfig } from '../../../../../libs/config/src/runtime-config';
import type { AktoolsDataClient, AktoolsRawRow } from './aktools.types';
export type AktoolsClientErrorKind =
| 'disabled'
| 'timeout'
| 'network'
| 'protocol'
| 'unavailable';
export class AktoolsClientError extends Error {
constructor(
readonly kind: AktoolsClientErrorKind,
message: string,
) {
super(message);
this.name = 'AktoolsClientError';
}
}
export interface AktoolsClientOptions extends AktoolsRuntimeConfig {
fetchImpl?: typeof fetch;
maxResponseBytes?: number;
}
const DEFAULT_MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
const ENDPOINTS = Object.freeze({
limitUp: 'stock_zt_pool_em',
brokenBoard: 'stock_zt_pool_zbgc_em',
previousLimitUp: 'stock_zt_pool_previous_em',
});
function isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === 'AbortError';
}
function assertTradeDate(tradeDate: string): void {
if (!/^\d{8}$/.test(tradeDate)) throw new AktoolsClientError('protocol', '交易日期格式错误');
}
/**
* AKTools 只作为固定的内网补充源,禁止把通用函数名和 URL 暴露给请求方。
* 客户端在这里统一处理上游网络和协议边界,业务层只接收数组记录。
*/
export function createAktoolsClient({
enabled,
baseUrl,
timeoutMs,
fetchImpl = fetch,
maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES,
}: AktoolsClientOptions): AktoolsDataClient {
const endpoint = `${baseUrl.replace(/\/$/, '')}/api/public/`;
async function request(path: string, tradeDate: string): Promise<AktoolsRawRow[]> {
assertTradeDate(tradeDate);
if (!enabled) throw new AktoolsClientError('disabled', 'AKTools 未启用');
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetchImpl(
`${endpoint}${path}?date=${encodeURIComponent(tradeDate)}`,
{
method: 'GET',
headers: { accept: 'application/json' },
signal: controller.signal,
},
);
const contentLength = response.headers.get('content-length');
if (contentLength && Number(contentLength) > maxResponseBytes) {
throw new AktoolsClientError('protocol', 'AKTools 响应体超过安全上限');
}
const text = await response.text();
if (Buffer.byteLength(text, 'utf8') > maxResponseBytes) {
throw new AktoolsClientError('protocol', 'AKTools 响应体超过安全上限');
}
if (!response.ok) throw new AktoolsClientError('unavailable', 'AKTools 服务不可用');
let value: unknown;
try {
value = JSON.parse(text);
} catch {
throw new AktoolsClientError('protocol', 'AKTools 返回格式错误');
}
if (!Array.isArray(value) || value.some((item) => !item || typeof item !== 'object')) {
throw new AktoolsClientError('protocol', 'AKTools 返回数据结构错误');
}
return value as AktoolsRawRow[];
} catch (error) {
if (error instanceof AktoolsClientError) throw error;
if (isAbortError(error)) throw new AktoolsClientError('timeout', 'AKTools 请求超时');
throw new AktoolsClientError('network', 'AKTools 网络请求失败');
} finally {
clearTimeout(timeout);
}
}
return {
getLimitUpPool: (tradeDate) => request(ENDPOINTS.limitUp, tradeDate),
getBrokenBoardPool: (tradeDate) => request(ENDPOINTS.brokenBoard, tradeDate),
getPreviousLimitUpPool: (tradeDate) => request(ENDPOINTS.previousLimitUp, tradeDate),
};
}
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import {
mapBrokenBoardPool,
mapLimitUpPool,
mapPreviousLimitUpPool,
} from './aktools.mapper';
describe('AKTools 超短字段映射', () => {
it('过滤缺失连板数的记录并生成连板梯队', () => {
expect(
mapLimitUpPool([
{ 代码: '000001', 名称: '甲', 连板数: 3, 所属行业: '软件服务', 封板资金: 100000000 },
{ 代码: '000002', 名称: '乙', 连板数: 2, 所属行业: '软件服务', 封板资金: 50000000 },
{ 代码: '000003', 名称: '丙', 连板数: 'bad', 所属行业: '汽车', 封板资金: 1 },
]),
).toEqual({
count: 2,
maxBoard: 3,
tiers: [
{ board: 2, count: 1 },
{ board: 3, count: 1 },
],
industries: [
{ name: '软件服务', count: 2 },
],
sealFundsAmount: 1.5,
});
});
it('统计有效炸板数量并忽略空记录', () => {
expect(mapBrokenBoardPool([{ 代码: '000001' }, {}, null as never])).toEqual({ count: 1 });
});
it('计算昨日涨停股涨跌分布和平均涨跌幅', () => {
expect(
mapPreviousLimitUpPool([
{ 代码: '000001', 涨跌幅: 2.5 },
{ 代码: '000002', 涨跌幅: -1 },
{ 代码: '000003', 涨跌幅: 0 },
{ 代码: '000004', 涨跌幅: 'bad' },
]),
).toEqual({
count: 3,
averageChangePercent: 0.5,
upCount: 1,
downCount: 1,
flatCount: 1,
});
});
});
@@ -0,0 +1,96 @@
import type { AktoolsRawRow } from './aktools.types';
export interface LimitUpSummary {
count: number;
maxBoard: number;
tiers: Array<{ board: number; count: number }>;
industries: Array<{ name: string; count: number }>;
sealFundsAmount: number;
}
export interface BrokenBoardSummary {
count: number;
}
export interface PreviousLimitUpSummary {
count: number;
averageChangePercent: number;
upCount: number;
downCount: number;
flatCount: number;
}
function toNumber(value: unknown): number | null {
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
if (typeof value !== 'string' || !value.trim()) return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function toPositiveInteger(value: unknown): number | null {
const parsed = toNumber(value);
return parsed !== null && Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
}
function hasCode(row: unknown): row is AktoolsRawRow {
if (!row || typeof row !== 'object') return false;
const code = (row as Record<string, unknown>)['代码'];
return typeof code === 'string' && code.trim().length > 0;
}
function round(value: number): number {
return Math.round(value * 100) / 100;
}
export function mapLimitUpPool(rows: readonly AktoolsRawRow[]): LimitUpSummary {
const validRows = rows.filter((row) => hasCode(row) && toPositiveInteger(row['连板数']) !== null);
const tierCounts = new Map<number, number>();
const industryCounts = new Map<string, number>();
let sealFunds = 0;
for (const row of validRows) {
const board = toPositiveInteger(row['连板数']) as number;
tierCounts.set(board, (tierCounts.get(board) ?? 0) + 1);
const industry = typeof row['所属行业'] === 'string' ? row['所属行业'].trim() : '';
if (industry) industryCounts.set(industry, (industryCounts.get(industry) ?? 0) + 1);
const funds = toNumber(row['封板资金']);
if (funds !== null && funds >= 0) sealFunds += funds;
}
return {
count: validRows.length,
maxBoard: Math.max(0, ...tierCounts.keys()),
tiers: [...tierCounts.entries()]
.map(([board, count]) => ({ board, count }))
.sort((left, right) => left.board - right.board),
industries: [...industryCounts.entries()]
.map(([name, count]) => ({ name, count }))
.sort((left, right) => right.count - left.count || left.name.localeCompare(right.name)),
sealFundsAmount: round(sealFunds / 100_000_000),
};
}
export function mapBrokenBoardPool(rows: readonly unknown[]): BrokenBoardSummary {
return { count: rows.filter((row) => hasCode(row)).length };
}
export function mapPreviousLimitUpPool(
rows: readonly AktoolsRawRow[],
): PreviousLimitUpSummary {
const changes = rows.flatMap((row) => {
if (!hasCode(row)) return [];
const change = toNumber(row['涨跌幅']);
return change === null ? [] : [change];
});
const upCount = changes.filter((value) => value > 0).length;
const downCount = changes.filter((value) => value < 0).length;
return {
count: changes.length,
averageChangePercent: changes.length
? round(changes.reduce((sum, value) => sum + value, 0) / changes.length)
: 0,
upCount,
downCount,
flatCount: changes.length - upCount - downCount,
};
}
@@ -0,0 +1,12 @@
export type AktoolsRawRow = Readonly<Record<string, unknown>>;
export interface AktoolsDataClient {
getLimitUpPool(tradeDate: string): Promise<AktoolsRawRow[]>;
getBrokenBoardPool(tradeDate: string): Promise<AktoolsRawRow[]>;
getPreviousLimitUpPool(tradeDate: string): Promise<AktoolsRawRow[]>;
}
export interface AktoolsShortTermSource {
source: 'akshare';
sourceUpdatedAt: string;
}
@@ -3,6 +3,9 @@ import { type DynamicModule, Module } from '@nestjs/common';
import type { ApiRuntimeConfig } from '../../../../libs/config/src/runtime-config';
import { MarketOverviewController } from './market-overview.controller';
import { MarketOverviewService, TDX_CLIENT_PORT } from './market-overview.service';
import { ShortTermMarketController } from './short-term.controller';
import { AKTOOLS_CLIENT_PORT, ShortTermMarketService } from './short-term.service';
import { createAktoolsClient } from './aktools/aktools.client';
import { createTdxClient } from './tdx/tdx.client';
@Module({})
@@ -11,10 +14,12 @@ export class MarketOverviewModule {
static register(config: ApiRuntimeConfig): DynamicModule {
return {
module: MarketOverviewModule,
controllers: [MarketOverviewController],
controllers: [MarketOverviewController, ShortTermMarketController],
providers: [
MarketOverviewService,
{ provide: TDX_CLIENT_PORT, useValue: createTdxClient(config.tdx) },
ShortTermMarketService,
{ provide: AKTOOLS_CLIENT_PORT, useValue: createAktoolsClient(config.aktools) },
],
};
}
@@ -0,0 +1,28 @@
import { Controller, Get, Inject } from '@nestjs/common';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import {
ShortTermConclusionResponseDto,
ShortTermEmotionResponseDto,
} from './short-term.dto';
import { ShortTermMarketService } from './short-term.service';
@ApiTags('market-overview-short-term')
@Controller('market-overview/short-term')
export class ShortTermMarketController {
constructor(
@Inject(ShortTermMarketService) private readonly service: ShortTermMarketService,
) {}
@Get('emotion')
@ApiOkResponse({ type: ShortTermEmotionResponseDto })
getEmotion(): Promise<ShortTermEmotionResponseDto> {
return this.service.getEmotion();
}
@Get('conclusion')
@ApiOkResponse({ type: ShortTermConclusionResponseDto })
getConclusion(): Promise<ShortTermConclusionResponseDto> {
return this.service.getConclusion();
}
}
@@ -0,0 +1,111 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export type ShortTermStatus = 'fresh' | 'delayed' | 'partial' | 'unavailable';
export class ShortTermTierDto {
@ApiProperty({ type: Number })
board!: number;
@ApiProperty({ type: Number })
count!: number;
}
export class ShortTermIndustryDto {
@ApiProperty({ type: String })
name!: string;
@ApiProperty({ type: Number })
count!: number;
}
export class ShortTermEmotionDataDto {
@ApiProperty({ type: String, example: '20260824' })
tradeDate!: string;
@ApiPropertyOptional({ type: Number, nullable: true })
limitUpCount!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true })
maxBoard!: number | null;
@ApiProperty({ type: [ShortTermTierDto] })
tiers!: ShortTermTierDto[];
@ApiPropertyOptional({ type: Number, nullable: true })
brokenBoardCount!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true })
previousAverageChangePercent!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true })
previousUpCount!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true })
previousDownCount!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true })
previousFlatCount!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true, description: '封板资金,亿元' })
sealFundsAmount!: number | null;
@ApiProperty({ type: [ShortTermIndustryDto] })
industries!: ShortTermIndustryDto[];
}
export class ShortTermConclusionDataDto {
@ApiProperty({ type: String })
verdict!: string;
@ApiProperty({ type: [String] })
drivers!: string[];
@ApiProperty({ type: [String] })
risks!: string[];
}
export class ShortTermEmotionResponseDto {
@ApiProperty({ type: String })
snapshotId!: string;
@ApiProperty({ type: String, format: 'date-time' })
asOf!: string;
@ApiProperty({ type: String, enum: ['akshare'] })
source!: 'akshare';
@ApiProperty({ type: String, example: '20260824' })
tradeDate!: string;
@ApiProperty({ enum: ['fresh', 'delayed', 'partial', 'unavailable'] })
status!: ShortTermStatus;
@ApiProperty({ type: () => ShortTermEmotionDataDto, nullable: true })
data!: ShortTermEmotionDataDto | null;
@ApiProperty({ type: [String] })
warnings!: string[];
}
export class ShortTermConclusionResponseDto {
@ApiProperty({ type: String })
snapshotId!: string;
@ApiProperty({ type: String, format: 'date-time' })
asOf!: string;
@ApiProperty({ type: String, enum: ['akshare'] })
source!: 'akshare';
@ApiProperty({ type: String, example: '20260824' })
tradeDate!: string;
@ApiProperty({ enum: ['fresh', 'delayed', 'partial', 'unavailable'] })
status!: ShortTermStatus;
@ApiProperty({ type: () => ShortTermConclusionDataDto, nullable: true })
data!: ShortTermConclusionDataDto | null;
@ApiProperty({ type: [String] })
warnings!: string[];
}
@@ -0,0 +1,75 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ShortTermMarketService } from './short-term.service';
describe('短线行情服务', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-08-24T03:00:00.000Z'));
});
afterEach(() => {
vi.useRealTimers();
});
it('组合涨停、炸板和昨日涨停数据并返回新鲜情绪快照', async () => {
const client = {
getLimitUpPool: vi.fn().mockResolvedValue([
{ 代码: '000001', 连板数: 4, 所属行业: '软件服务', 封板资金: 100_000_000 },
]),
getBrokenBoardPool: vi.fn().mockResolvedValue([{ 代码: '000002' }]),
getPreviousLimitUpPool: vi.fn().mockResolvedValue([{ 代码: '000003', 涨跌幅: 2 }]),
};
const service = new ShortTermMarketService(client);
const response = await service.getEmotion();
expect(response).toMatchObject({
source: 'akshare',
status: 'fresh',
data: {
tradeDate: '20260824',
limitUpCount: 1,
maxBoard: 4,
brokenBoardCount: 1,
previousAverageChangePercent: 2,
},
});
expect(client.getLimitUpPool).toHaveBeenCalledWith('20260824');
expect(client.getBrokenBoardPool).toHaveBeenCalledWith('20260824');
expect(client.getPreviousLimitUpPool).toHaveBeenCalledWith('20260824');
});
it('单个 AKTools 模块失败时保留其他结果并标记 partial', async () => {
const client = {
getLimitUpPool: vi.fn().mockResolvedValue([{ 代码: '000001', 连板数: 2 }]),
getBrokenBoardPool: vi.fn().mockRejectedValue(new Error('上游详情不应透传')),
getPreviousLimitUpPool: vi.fn().mockResolvedValue([{ 代码: '000003', 涨跌幅: -1 }]),
};
const service = new ShortTermMarketService(client);
const response = await service.getEmotion();
expect(response.status).toBe('partial');
expect(response.data).toMatchObject({ limitUpCount: 1, previousAverageChangePercent: -1 });
expect(response.warnings.join(' ')).toContain('炸板池');
expect(response.warnings.join(' ')).not.toContain('上游详情');
});
it('根据结构化指标生成保守的偏强结论', async () => {
const client = {
getLimitUpPool: vi.fn().mockResolvedValue([{ 代码: '000001', 连板数: 4 }]),
getBrokenBoardPool: vi.fn().mockResolvedValue([]),
getPreviousLimitUpPool: vi.fn().mockResolvedValue([{ 代码: '000003', 涨跌幅: 2 }]),
};
const service = new ShortTermMarketService(client);
const response = await service.getConclusion();
expect(response).toMatchObject({
source: 'akshare',
status: 'fresh',
data: { verdict: '超短情绪偏强' },
});
});
});
@@ -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;
}
}
@@ -0,0 +1,80 @@
import type { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ShortTermMarketController } from '../src/market-overview/short-term.controller';
import { AKTOOLS_CLIENT_PORT, ShortTermMarketService } from '../src/market-overview/short-term.service';
describe('短线行情 HTTP API', () => {
let app: INestApplication | undefined;
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-08-24T03:00:00.000Z'));
});
afterEach(async () => {
try {
await app?.close();
} finally {
app = undefined;
vi.useRealTimers();
}
});
it('返回结构化超短情绪指标和 AKShare 来源', async () => {
const client = {
getLimitUpPool: vi.fn().mockResolvedValue([{ 代码: '000001', 连板数: 4 }]),
getBrokenBoardPool: vi.fn().mockResolvedValue([{ 代码: '000002' }]),
getPreviousLimitUpPool: vi.fn().mockResolvedValue([{ 代码: '000003', 涨跌幅: 1.5 }]),
};
const moduleRef = await Test.createTestingModule({
controllers: [ShortTermMarketController],
providers: [
ShortTermMarketService,
{ provide: AKTOOLS_CLIENT_PORT, useValue: client },
],
}).compile();
app = moduleRef.createNestApplication();
app.setGlobalPrefix('api/v1');
await app.init();
const response = await request(app.getHttpServer())
.get('/api/v1/market-overview/short-term/emotion')
.expect(200);
expect(response.body).toMatchObject({
source: 'akshare',
status: 'fresh',
tradeDate: '20260824',
data: { limitUpCount: 1, maxBoard: 4, brokenBoardCount: 1 },
});
expect(JSON.stringify(response.body)).not.toContain('127.0.0.1');
});
it('全部 AKTools 数据不可用时返回 unavailable 而不是伪造空数据', async () => {
const client = {
getLimitUpPool: vi.fn().mockRejectedValue(new Error('private detail')),
getBrokenBoardPool: vi.fn().mockRejectedValue(new Error('private detail')),
getPreviousLimitUpPool: vi.fn().mockRejectedValue(new Error('private detail')),
};
const moduleRef = await Test.createTestingModule({
controllers: [ShortTermMarketController],
providers: [
ShortTermMarketService,
{ provide: AKTOOLS_CLIENT_PORT, useValue: client },
],
}).compile();
app = moduleRef.createNestApplication();
app.setGlobalPrefix('api/v1');
await app.init();
const response = await request(app.getHttpServer())
.get('/api/v1/market-overview/short-term/conclusion')
.expect(200);
expect(response.body).toMatchObject({ source: 'akshare', status: 'unavailable', data: null });
expect(JSON.stringify(response.body)).not.toContain('private detail');
});
});