feat(FEAT-DS-001): 接入AKTools超短行情补充数据源
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,12 @@ const disabledAiEnv = {
|
||||
AI_TIMEOUT_MS: '30000',
|
||||
};
|
||||
|
||||
const enabledAktoolsEnv = {
|
||||
AKTOOLS_ENABLED: 'true',
|
||||
AKTOOLS_BASE_URL: 'http://127.0.0.1:8080',
|
||||
AKTOOLS_TIMEOUT_MS: '10000',
|
||||
};
|
||||
|
||||
const disabledRocketMqEnv = {
|
||||
ROCKETMQ_ENABLED: 'false',
|
||||
ROCKETMQ_NAMESRV_ADDR: '127.0.0.1:9876',
|
||||
@@ -44,6 +50,27 @@ const disabledRocketMqEnv = {
|
||||
};
|
||||
|
||||
describe('进程级运行配置', () => {
|
||||
it('API 接受仅限本机 8080 的 AKTools 配置', () => {
|
||||
const config = loadApiConfig({
|
||||
APP_ENV: 'development',
|
||||
LOG_LEVEL: 'debug',
|
||||
API_HOST: '127.0.0.1',
|
||||
API_PORT: '3000',
|
||||
TDX_BASE_URL: 'http://127.0.0.1:17710',
|
||||
TDX_TIMEOUT_MS: '5000',
|
||||
...baseRedisEnv,
|
||||
...disabledMysqlEnv,
|
||||
...disabledAiEnv,
|
||||
...enabledAktoolsEnv,
|
||||
});
|
||||
|
||||
expect(config.aktools).toEqual({
|
||||
enabled: true,
|
||||
baseUrl: 'http://127.0.0.1:8080',
|
||||
timeoutMs: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
it('API 只返回自己的配置且不要求 Scheduler 或 Worker 字段', () => {
|
||||
const config = loadApiConfig({
|
||||
APP_ENV: 'development',
|
||||
|
||||
@@ -41,6 +41,13 @@ export interface TdxRuntimeConfig {
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
/** AKTools 仅作为本机内网补充源,启用时固定回环地址和端口。 */
|
||||
export interface AktoolsRuntimeConfig {
|
||||
enabled: boolean;
|
||||
baseUrl: string;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
/** RocketMQ 公共寻址配置;实际生产/消费参数由相应进程扩展。 */
|
||||
export interface RocketMqRuntimeConfig {
|
||||
enabled: boolean;
|
||||
@@ -61,6 +68,7 @@ export interface ApiRuntimeConfig extends CommonRuntimeConfig {
|
||||
processName: 'api';
|
||||
api: Readonly<{ host: string; port: number }>;
|
||||
tdx: Readonly<TdxRuntimeConfig>;
|
||||
aktools: Readonly<AktoolsRuntimeConfig>;
|
||||
mysql: MysqlRuntimeConfig;
|
||||
ai: AiRuntimeConfig;
|
||||
}
|
||||
@@ -227,6 +235,35 @@ function loadTdxConfig(env: EnvironmentSource): TdxRuntimeConfig {
|
||||
};
|
||||
}
|
||||
|
||||
function loadAktoolsConfig(env: EnvironmentSource): AktoolsRuntimeConfig {
|
||||
const enabled = env.AKTOOLS_ENABLED === undefined ? false : parseBoolean(env, 'AKTOOLS_ENABLED');
|
||||
const baseUrl = optionalText(env, 'AKTOOLS_BASE_URL') || 'http://127.0.0.1:8080';
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(baseUrl);
|
||||
} catch {
|
||||
throw new Error('AKTOOLS_BASE_URL 必须为有效 URL');
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'http:') throw new Error('AKTOOLS_BASE_URL 必须使用 http 协议');
|
||||
if (parsed.hostname !== '127.0.0.1') {
|
||||
throw new Error('AKTOOLS_BASE_URL 必须指向本机回环地址');
|
||||
}
|
||||
if (parsed.port !== '8080') throw new Error('AKTOOLS_BASE_URL 必须使用 8080 端口');
|
||||
if (parsed.pathname !== '' && parsed.pathname !== '/') {
|
||||
throw new Error('AKTOOLS_BASE_URL 不得包含额外路径');
|
||||
}
|
||||
|
||||
return {
|
||||
enabled,
|
||||
baseUrl: parsed.toString().replace(/\/$/, ''),
|
||||
timeoutMs:
|
||||
env.AKTOOLS_TIMEOUT_MS === undefined
|
||||
? 10_000
|
||||
: parseInteger(env, 'AKTOOLS_TIMEOUT_MS', { min: 100, max: 30_000 }),
|
||||
};
|
||||
}
|
||||
|
||||
function loadRocketMqConfig(env: EnvironmentSource): RocketMqRuntimeConfig {
|
||||
const enabled = parseBoolean(env, 'ROCKETMQ_ENABLED');
|
||||
const nameServerAddress = optionalText(env, 'ROCKETMQ_NAMESRV_ADDR');
|
||||
@@ -257,6 +294,7 @@ export function loadApiConfig(env: EnvironmentSource): ApiRuntimeConfig {
|
||||
port: parseInteger(env, 'API_PORT', { min: 1, max: 65_535 }),
|
||||
},
|
||||
tdx: loadTdxConfig(env),
|
||||
aktools: loadAktoolsConfig(env),
|
||||
redis: loadRedisConfig(env),
|
||||
mysql: loadMysqlConfig(env),
|
||||
ai: loadAiConfig(env),
|
||||
@@ -335,6 +373,7 @@ export function toSafeRuntimeSummary(config: RuntimeConfig): Readonly<Record<str
|
||||
};
|
||||
|
||||
if (config.processName === 'api') summary.api = config.api;
|
||||
if (config.processName === 'api') summary.aktools = { enabled: config.aktools.enabled };
|
||||
if (config.processName === 'scheduler') summary.scheduler = config.scheduler;
|
||||
if (config.processName === 'worker') summary.marketData = { mode: config.marketData.mode };
|
||||
|
||||
|
||||
@@ -182,6 +182,101 @@
|
||||
"required": ["snapshotId", "asOf", "source", "status", "warnings", "data"],
|
||||
"type": "object"
|
||||
},
|
||||
"ShortTermConclusionDataDto": {
|
||||
"properties": {
|
||||
"drivers": { "items": { "type": "string" }, "type": "array" },
|
||||
"risks": { "items": { "type": "string" }, "type": "array" },
|
||||
"verdict": { "type": "string" }
|
||||
},
|
||||
"required": ["verdict", "drivers", "risks"],
|
||||
"type": "object"
|
||||
},
|
||||
"ShortTermConclusionResponseDto": {
|
||||
"properties": {
|
||||
"asOf": { "format": "date-time", "type": "string" },
|
||||
"data": {
|
||||
"allOf": [{ "$ref": "#/components/schemas/ShortTermConclusionDataDto" }],
|
||||
"nullable": true,
|
||||
"type": "object"
|
||||
},
|
||||
"snapshotId": { "type": "string" },
|
||||
"source": { "enum": ["akshare"], "type": "string" },
|
||||
"status": {
|
||||
"enum": ["fresh", "delayed", "partial", "unavailable"],
|
||||
"type": "string"
|
||||
},
|
||||
"tradeDate": { "example": "20260824", "type": "string" },
|
||||
"warnings": { "items": { "type": "string" }, "type": "array" }
|
||||
},
|
||||
"required": ["snapshotId", "asOf", "source", "tradeDate", "status", "data", "warnings"],
|
||||
"type": "object"
|
||||
},
|
||||
"ShortTermEmotionDataDto": {
|
||||
"properties": {
|
||||
"brokenBoardCount": { "nullable": true, "type": "number" },
|
||||
"industries": {
|
||||
"items": { "$ref": "#/components/schemas/ShortTermIndustryDto" },
|
||||
"type": "array"
|
||||
},
|
||||
"limitUpCount": { "nullable": true, "type": "number" },
|
||||
"maxBoard": { "nullable": true, "type": "number" },
|
||||
"previousAverageChangePercent": {
|
||||
"nullable": true,
|
||||
"type": "number"
|
||||
},
|
||||
"previousDownCount": { "nullable": true, "type": "number" },
|
||||
"previousFlatCount": { "nullable": true, "type": "number" },
|
||||
"previousUpCount": { "nullable": true, "type": "number" },
|
||||
"sealFundsAmount": {
|
||||
"description": "封板资金,亿元",
|
||||
"nullable": true,
|
||||
"type": "number"
|
||||
},
|
||||
"tiers": {
|
||||
"items": { "$ref": "#/components/schemas/ShortTermTierDto" },
|
||||
"type": "array"
|
||||
},
|
||||
"tradeDate": { "example": "20260824", "type": "string" }
|
||||
},
|
||||
"required": ["tradeDate", "tiers", "industries"],
|
||||
"type": "object"
|
||||
},
|
||||
"ShortTermEmotionResponseDto": {
|
||||
"properties": {
|
||||
"asOf": { "format": "date-time", "type": "string" },
|
||||
"data": {
|
||||
"allOf": [{ "$ref": "#/components/schemas/ShortTermEmotionDataDto" }],
|
||||
"nullable": true,
|
||||
"type": "object"
|
||||
},
|
||||
"snapshotId": { "type": "string" },
|
||||
"source": { "enum": ["akshare"], "type": "string" },
|
||||
"status": {
|
||||
"enum": ["fresh", "delayed", "partial", "unavailable"],
|
||||
"type": "string"
|
||||
},
|
||||
"tradeDate": { "example": "20260824", "type": "string" },
|
||||
"warnings": { "items": { "type": "string" }, "type": "array" }
|
||||
},
|
||||
"required": ["snapshotId", "asOf", "source", "tradeDate", "status", "data", "warnings"],
|
||||
"type": "object"
|
||||
},
|
||||
"ShortTermIndustryDto": {
|
||||
"properties": {
|
||||
"count": { "type": "number" },
|
||||
"name": { "type": "string" }
|
||||
},
|
||||
"required": ["name", "count"],
|
||||
"type": "object"
|
||||
},
|
||||
"ShortTermTierDto": {
|
||||
"properties": {
|
||||
"board": { "type": "number" },
|
||||
"count": { "type": "number" }
|
||||
},
|
||||
"required": ["board", "count"],
|
||||
"type": "object"
|
||||
},
|
||||
"SummaryDataDto": {
|
||||
"properties": {
|
||||
"drivers": { "items": { "type": "string" }, "type": "array" },
|
||||
@@ -323,6 +418,44 @@
|
||||
"tags": ["market-overview"]
|
||||
}
|
||||
},
|
||||
"/api/v1/market-overview/short-term/conclusion": {
|
||||
"get": {
|
||||
"operationId": "ShortTermMarketController_getConclusion",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ShortTermConclusionResponseDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"tags": ["market-overview-short-term"]
|
||||
}
|
||||
},
|
||||
"/api/v1/market-overview/short-term/emotion": {
|
||||
"get": {
|
||||
"operationId": "ShortTermMarketController_getEmotion",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ShortTermEmotionResponseDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"tags": ["market-overview-short-term"]
|
||||
}
|
||||
},
|
||||
"/api/v1/market-overview/summary": {
|
||||
"get": {
|
||||
"operationId": "MarketOverviewController_getSummary",
|
||||
|
||||
@@ -60,6 +60,7 @@ describe('NestJS 三进程入口', () => {
|
||||
logLevel: 'debug',
|
||||
api: Object.freeze({ host: '127.0.0.1', port: 3000 }),
|
||||
tdx: Object.freeze({ baseUrl: 'http://127.0.0.1:17710', timeoutMs: 5000 }),
|
||||
aktools: Object.freeze({ baseUrl: 'http://127.0.0.1:8080', enabled: false, timeoutMs: 10000 }),
|
||||
redis: redisConfig,
|
||||
mysql: mysqlConfig,
|
||||
ai: aiConfig,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { format } from 'prettier';
|
||||
|
||||
import { HealthController } from '../apps/api/src/health.controller';
|
||||
import { MarketOverviewController } from '../apps/api/src/market-overview/market-overview.controller';
|
||||
import { ShortTermMarketController } from '../apps/api/src/market-overview/short-term.controller';
|
||||
import {
|
||||
HealthService,
|
||||
MYSQL_HEALTH_PORT,
|
||||
@@ -18,14 +19,28 @@ import {
|
||||
MarketOverviewService,
|
||||
TDX_CLIENT_PORT,
|
||||
} from '../apps/api/src/market-overview/market-overview.service';
|
||||
import { createAktoolsClient } from '../apps/api/src/market-overview/aktools/aktools.client';
|
||||
import {
|
||||
AKTOOLS_CLIENT_PORT,
|
||||
ShortTermMarketService,
|
||||
} from '../apps/api/src/market-overview/short-term.service';
|
||||
import { DisabledHealthClient } from '../libs/infrastructure/src/disabled-health.client';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController, MarketOverviewController],
|
||||
controllers: [HealthController, MarketOverviewController, ShortTermMarketController],
|
||||
providers: [
|
||||
HealthService,
|
||||
MarketOverviewService,
|
||||
{ provide: TDX_CLIENT_PORT, useValue: { request: async () => ({}) } },
|
||||
ShortTermMarketService,
|
||||
{
|
||||
provide: AKTOOLS_CLIENT_PORT,
|
||||
useValue: createAktoolsClient({
|
||||
enabled: false,
|
||||
baseUrl: 'http://127.0.0.1:8080',
|
||||
timeoutMs: 10_000,
|
||||
}),
|
||||
},
|
||||
{ provide: REDIS_HEALTH_PORT, useValue: new DisabledHealthClient('文档生成不连接 Redis') },
|
||||
{ provide: MYSQL_HEALTH_PORT, useValue: new DisabledHealthClient('文档生成不连接 MySQL') },
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user