feat(FEAT-MO-001): 接入TDX市场宽度板块与成交额趋势
This commit is contained in:
@@ -72,18 +72,44 @@ export class BreadthDataDto {
|
||||
@ApiProperty({ type: Number })
|
||||
declining!: number;
|
||||
|
||||
@ApiProperty({ type: Number })
|
||||
unchanged!: number;
|
||||
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||
unchanged!: number | null;
|
||||
}
|
||||
|
||||
export class SectorQuoteDto {
|
||||
@ApiProperty({ type: String })
|
||||
code!: string;
|
||||
|
||||
@ApiProperty({ type: String })
|
||||
name!: string;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, nullable: true })
|
||||
changePercent!: number | null;
|
||||
|
||||
@ApiProperty({ enum: ['up', 'down', 'flat'] })
|
||||
direction!: QuoteDirection;
|
||||
|
||||
@ApiProperty({ type: String })
|
||||
tradeDate!: string;
|
||||
}
|
||||
|
||||
export class SectorDataDto {
|
||||
@ApiProperty({ type: [String] })
|
||||
items!: string[];
|
||||
@ApiProperty({ type: [SectorQuoteDto] })
|
||||
items!: SectorQuoteDto[];
|
||||
}
|
||||
|
||||
export class FundDataDto {
|
||||
@ApiProperty({ type: [String] })
|
||||
items!: string[];
|
||||
@ApiProperty({ type: Number })
|
||||
currentAmount!: number;
|
||||
|
||||
@ApiProperty({ type: Number })
|
||||
previousAmount!: number;
|
||||
|
||||
@ApiProperty({ type: Number })
|
||||
changePercent!: number;
|
||||
|
||||
@ApiProperty({ type: String, example: '亿元' })
|
||||
unit!: '亿元';
|
||||
}
|
||||
|
||||
export class SummaryResponseDto extends MarketOverviewMetaDto {
|
||||
|
||||
@@ -19,6 +19,63 @@ const indexResult = {
|
||||
),
|
||||
};
|
||||
|
||||
const breadthSnapshot = (up: string, down: string) => ({
|
||||
ErrorId: '0',
|
||||
UpHome: up,
|
||||
DownHome: down,
|
||||
ItemNum: '0',
|
||||
});
|
||||
|
||||
const sectorResult = {
|
||||
ErrorId: '0',
|
||||
Value: [
|
||||
{ Code: '881319.SH', Name: '半导体' },
|
||||
{ Code: '881355.SH', Name: '软件服务' },
|
||||
],
|
||||
};
|
||||
|
||||
const sectorKlineResult = {
|
||||
ErrorId: '0',
|
||||
Value: {
|
||||
'881319.SH': {
|
||||
Date: ['20260817', '20260818'],
|
||||
Open: ['100', '105'],
|
||||
High: ['106', '110'],
|
||||
Low: ['99', '104'],
|
||||
Close: ['100', '108'],
|
||||
Amount: ['1000', '1200'],
|
||||
Volume: ['100', '120'],
|
||||
},
|
||||
'881355.SH': {
|
||||
Date: ['20260817', '20260818'],
|
||||
Open: ['100', '98'],
|
||||
High: ['101', '99'],
|
||||
Low: ['97', '95'],
|
||||
Close: ['100', '96'],
|
||||
Amount: ['1000', '900'],
|
||||
Volume: ['100', '90'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const turnoverResult = {
|
||||
ErrorId: '0',
|
||||
Value: Object.fromEntries(
|
||||
['000001.SH', '399001.SZ', '899050.BJ'].map((code) => [
|
||||
code,
|
||||
{
|
||||
Date: ['20260817', '20260818'],
|
||||
Open: ['100', '100'],
|
||||
High: ['101', '101'],
|
||||
Low: ['99', '99'],
|
||||
Close: ['100', '100'],
|
||||
Amount: ['100000', '120000'],
|
||||
Volume: ['1000', '1100'],
|
||||
},
|
||||
]),
|
||||
),
|
||||
};
|
||||
|
||||
describe('市场概览服务', () => {
|
||||
it('读取指数并返回统一快照元数据', async () => {
|
||||
const client = { request: vi.fn().mockResolvedValue(indexResult) };
|
||||
@@ -38,14 +95,69 @@ describe('市场概览服务', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('对未验证的市场宽度能力返回不可用而不是伪造数据', async () => {
|
||||
const client = { request: vi.fn() };
|
||||
it('汇总三个市场指数的上涨和下跌家数并保留未知平盘数', async () => {
|
||||
const client = {
|
||||
request: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(breadthSnapshot('10', '5'))
|
||||
.mockResolvedValueOnce(breadthSnapshot('20', '15'))
|
||||
.mockResolvedValueOnce(breadthSnapshot('3', '2')),
|
||||
};
|
||||
const service = new MarketOverviewService(client);
|
||||
|
||||
const response = await service.getBreadth();
|
||||
|
||||
expect(response).toMatchObject({ source: 'tdx', status: 'unavailable', data: null });
|
||||
expect(response.warnings[0]).toContain('市场宽度');
|
||||
expect(client.request).not.toHaveBeenCalled();
|
||||
expect(response).toMatchObject({
|
||||
source: 'tdx',
|
||||
status: 'fresh',
|
||||
data: { advancing: 33, declining: 22, unchanged: null },
|
||||
});
|
||||
expect(client.request).toHaveBeenCalledWith(
|
||||
'get_market_snapshot',
|
||||
expect.objectContaining({ stock_code: '000001.SH' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('按一级行业涨跌幅排序板块并保留 TDX 代码', async () => {
|
||||
const client = {
|
||||
request: vi.fn().mockResolvedValueOnce(sectorResult).mockResolvedValueOnce(sectorKlineResult),
|
||||
};
|
||||
const service = new MarketOverviewService(client);
|
||||
|
||||
const response = await service.getSectors();
|
||||
|
||||
expect(response).toMatchObject({
|
||||
source: 'tdx',
|
||||
status: 'fresh',
|
||||
data: {
|
||||
items: [
|
||||
expect.objectContaining({ code: '881319.SH', name: '半导体', changePercent: 8 }),
|
||||
expect.objectContaining({ code: '881355.SH', name: '软件服务', changePercent: -4 }),
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(client.request).toHaveBeenNthCalledWith(1, 'get_stock_list', {
|
||||
market: '16',
|
||||
list_type: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('按交易所指数成交额计算亿元级趋势而不宣称主力净流入', async () => {
|
||||
const client = { request: vi.fn().mockResolvedValue(turnoverResult) };
|
||||
const service = new MarketOverviewService(client);
|
||||
|
||||
const response = await service.getFunds();
|
||||
|
||||
expect(response).toMatchObject({
|
||||
source: 'tdx',
|
||||
status: 'fresh',
|
||||
data: {
|
||||
currentAmount: 36,
|
||||
previousAmount: 30,
|
||||
changePercent: 20,
|
||||
unit: '亿元',
|
||||
},
|
||||
});
|
||||
expect(response.warnings).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,9 +8,19 @@ import type {
|
||||
SummaryDataDto,
|
||||
} from './market-overview.dto';
|
||||
import { CORE_INDEXES } from './tdx/tdx.types';
|
||||
import { mapIndexQuotes } from './tdx/tdx.mapper';
|
||||
import {
|
||||
mapBreadthSnapshots,
|
||||
mapIndexQuotes,
|
||||
mapSectorQuotes,
|
||||
mapTurnoverTrend,
|
||||
} from './tdx/tdx.mapper';
|
||||
import { TdxClientError, type TdxClient } from './tdx/tdx.client';
|
||||
import type { TdxMarketDataResult } from './tdx/tdx.types';
|
||||
import type {
|
||||
TdxMarketDataResult,
|
||||
TdxMarketSnapshotResult,
|
||||
TdxStockListResult,
|
||||
} from './tdx/tdx.types';
|
||||
import { BREADTH_INDEXES, TURNOVER_INDEXES } from './tdx/tdx.types';
|
||||
|
||||
export const TDX_CLIENT_PORT = Symbol('TDX_CLIENT_PORT');
|
||||
|
||||
@@ -32,10 +42,6 @@ function createSnapshotMeta(): Pick<
|
||||
return { snapshotId, asOf, source: 'tdx' };
|
||||
}
|
||||
|
||||
function unavailable<TData>(warning: string): MarketOverviewResponse<TData> {
|
||||
return { ...createSnapshotMeta(), status: 'unavailable', data: null, warnings: [warning] };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MarketOverviewService {
|
||||
constructor(@Inject(TDX_CLIENT_PORT) private readonly tdxClient: TdxClient) {}
|
||||
@@ -89,24 +95,110 @@ export class MarketOverviewService {
|
||||
? '市场维持偏强震荡'
|
||||
: '市场维持偏弱震荡',
|
||||
marketStatus,
|
||||
volumeStatus: '成交量口径待接入',
|
||||
sentimentStatus: '市场情绪口径待接入',
|
||||
volumeStatus: '成交额趋势见下方',
|
||||
sentimentStatus: '涨跌家数见下方',
|
||||
drivers: changes.length ? [`核心指数平均涨跌幅 ${averageChange}%`] : ['核心指数数据不足'],
|
||||
risks: ['市场宽度、板块和资金指标尚未由当前 TDX 桥接层提供'],
|
||||
risks: ['主力净流入和盘中新闻仍待补充数据源验证'],
|
||||
};
|
||||
return { ...indices, data: summary };
|
||||
}
|
||||
|
||||
async getBreadth(): Promise<MarketOverviewResponse<BreadthDataDto>> {
|
||||
return unavailable<BreadthDataDto>('当前 TDX 桥接层未提供市场宽度方法');
|
||||
const meta = createSnapshotMeta();
|
||||
const results = await Promise.allSettled(
|
||||
BREADTH_INDEXES.map((item) =>
|
||||
this.tdxClient.request<TdxMarketSnapshotResult>('get_market_snapshot', {
|
||||
stock_code: item.code,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const snapshots = results.flatMap((result) =>
|
||||
result.status === 'fulfilled' ? [result.value] : [],
|
||||
);
|
||||
if (!snapshots.length) {
|
||||
return {
|
||||
...meta,
|
||||
status: 'unavailable',
|
||||
data: null,
|
||||
warnings: [
|
||||
this.toWarning(
|
||||
results[0]?.status === 'rejected' ? results[0].reason : undefined,
|
||||
'市场宽度',
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
const status = snapshots.length === BREADTH_INDEXES.length ? 'fresh' : 'partial';
|
||||
return {
|
||||
...meta,
|
||||
status,
|
||||
data: mapBreadthSnapshots(snapshots),
|
||||
warnings: status === 'partial' ? ['部分市场指数未返回涨跌家数'] : [],
|
||||
};
|
||||
}
|
||||
|
||||
async getSectors(): Promise<MarketOverviewResponse<SectorDataDto>> {
|
||||
return unavailable<SectorDataDto>('当前 TDX 桥接层未提供板块排行方法');
|
||||
const meta = createSnapshotMeta();
|
||||
try {
|
||||
const catalog = await this.tdxClient.request<TdxStockListResult>('get_stock_list', {
|
||||
market: '16',
|
||||
list_type: 1,
|
||||
});
|
||||
const definitions = catalog.Value.map(({ Code: code, Name: name }) => ({ code, name }));
|
||||
const result = await this.tdxClient.request<TdxMarketDataResult>('get_market_data', {
|
||||
stock_list: definitions.map((item) => item.code),
|
||||
count: 2,
|
||||
dividend_type: 'none',
|
||||
period: '1d',
|
||||
});
|
||||
const mappedItems = mapSectorQuotes(result, definitions);
|
||||
const items = mappedItems.slice(0, 8);
|
||||
const status =
|
||||
mappedItems.length === definitions.length
|
||||
? 'fresh'
|
||||
: mappedItems.length
|
||||
? 'partial'
|
||||
: 'unavailable';
|
||||
return {
|
||||
...meta,
|
||||
status,
|
||||
data: items.length ? { items } : null,
|
||||
warnings:
|
||||
status === 'fresh'
|
||||
? []
|
||||
: [status === 'partial' ? '部分一级行业未返回有效行情' : '一级行业行情不可用'],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
...meta,
|
||||
status: 'unavailable',
|
||||
data: null,
|
||||
warnings: [this.toWarning(error, '板块行情')],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async getFunds(): Promise<MarketOverviewResponse<FundDataDto>> {
|
||||
return unavailable<FundDataDto>('当前 TDX 桥接层未提供资金趋势方法');
|
||||
const meta = createSnapshotMeta();
|
||||
try {
|
||||
const result = await this.tdxClient.request<TdxMarketDataResult>('get_market_data', {
|
||||
stock_list: TURNOVER_INDEXES.map((item) => item.code),
|
||||
count: 2,
|
||||
dividend_type: 'none',
|
||||
period: '1d',
|
||||
});
|
||||
const data = mapTurnoverTrend(result, TURNOVER_INDEXES);
|
||||
if (!data)
|
||||
return { ...meta, status: 'unavailable', data: null, warnings: ['成交额趋势数据不完整'] };
|
||||
return { ...meta, status: 'fresh', data, warnings: [] };
|
||||
} catch (error) {
|
||||
return {
|
||||
...meta,
|
||||
status: 'unavailable',
|
||||
data: null,
|
||||
warnings: [this.toWarning(error, '成交额趋势')],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private toWarning(error: unknown, subject: string): string {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { mapIndexQuotes } from './tdx.mapper';
|
||||
import { mapBreadthSnapshots, mapIndexQuotes, mapTurnoverTrend } from './tdx.mapper';
|
||||
|
||||
describe('TDX 行情映射', () => {
|
||||
it('将 K 线数组转换为 A 股红涨绿跌的指数报价', () => {
|
||||
@@ -55,4 +55,35 @@ describe('TDX 行情映射', () => {
|
||||
|
||||
expect(result[0]).toMatchObject({ price: 2105, changePercent: null, direction: 'flat' });
|
||||
});
|
||||
|
||||
it('市场宽度映射只汇总可用的上涨和下跌家数', () => {
|
||||
expect(
|
||||
mapBreadthSnapshots([
|
||||
{ UpHome: '10', DownHome: '5' },
|
||||
{ UpHome: '2', DownHome: '3' },
|
||||
]),
|
||||
).toEqual({ advancing: 12, declining: 8, unchanged: null });
|
||||
});
|
||||
|
||||
it('成交额映射将 TDX 万元转换为亿元并计算环比', () => {
|
||||
expect(
|
||||
mapTurnoverTrend(
|
||||
{
|
||||
ErrorId: '0',
|
||||
Value: {
|
||||
'000001.SH': {
|
||||
Date: ['20260817', '20260818'],
|
||||
Open: ['1', '1'],
|
||||
High: ['1', '1'],
|
||||
Low: ['1', '1'],
|
||||
Close: ['1', '1'],
|
||||
Amount: ['100000', '120000'],
|
||||
Volume: ['1', '1'],
|
||||
},
|
||||
},
|
||||
},
|
||||
[{ code: '000001.SH', name: '上证市场' }],
|
||||
),
|
||||
).toEqual({ currentAmount: 12, previousAmount: 10, changePercent: 20, unit: '亿元' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { IndexQuoteDto } from '../market-overview.dto';
|
||||
import type {
|
||||
BreadthDataDto,
|
||||
FundDataDto,
|
||||
IndexQuoteDto,
|
||||
SectorQuoteDto,
|
||||
} from '../market-overview.dto';
|
||||
import type { IndexDefinition, TdxMarketDataResult, TdxKlineSeries } from './tdx.types';
|
||||
|
||||
function toNumber(value: string | undefined): number | null {
|
||||
@@ -46,3 +51,51 @@ export function mapIndexQuotes(
|
||||
return quote ? [quote] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export function mapBreadthSnapshots(
|
||||
snapshots: readonly { UpHome?: string; DownHome?: string }[],
|
||||
): BreadthDataDto {
|
||||
const advancing = snapshots.reduce((total, item) => total + (toNumber(item.UpHome) ?? 0), 0);
|
||||
const declining = snapshots.reduce((total, item) => total + (toNumber(item.DownHome) ?? 0), 0);
|
||||
return { advancing, declining, unchanged: null };
|
||||
}
|
||||
|
||||
export function mapSectorQuotes(
|
||||
result: TdxMarketDataResult,
|
||||
definitions: readonly IndexDefinition[],
|
||||
): SectorQuoteDto[] {
|
||||
return mapIndexQuotes(result, definitions).sort((left, right) => {
|
||||
return (
|
||||
(right.changePercent ?? Number.NEGATIVE_INFINITY) -
|
||||
(left.changePercent ?? Number.NEGATIVE_INFINITY)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function mapTurnoverTrend(
|
||||
result: TdxMarketDataResult,
|
||||
definitions: readonly IndexDefinition[],
|
||||
): FundDataDto | null {
|
||||
let currentAmount = 0;
|
||||
let previousAmount = 0;
|
||||
let validSeries = 0;
|
||||
|
||||
for (const definition of definitions) {
|
||||
const series = result.Value[definition.code];
|
||||
if (!series) continue;
|
||||
const current = toNumber(series.Amount.at(-1));
|
||||
const previous = toNumber(series.Amount.at(-2));
|
||||
if (current === null || previous === null) continue;
|
||||
currentAmount += current;
|
||||
previousAmount += previous;
|
||||
validSeries += 1;
|
||||
}
|
||||
|
||||
if (!validSeries || previousAmount === 0) return null;
|
||||
return {
|
||||
currentAmount: Math.round((currentAmount / 10000) * 100) / 100,
|
||||
previousAmount: Math.round((previousAmount / 10000) * 100) / 100,
|
||||
changePercent: Math.round(((currentAmount - previousAmount) / previousAmount) * 10000) / 100,
|
||||
unit: '亿元',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,6 +14,20 @@ export interface TdxMarketDataResult {
|
||||
Value: Record<string, TdxKlineSeries>;
|
||||
}
|
||||
|
||||
export interface TdxMarketSnapshotResult {
|
||||
ErrorId: string | number;
|
||||
UpHome?: string;
|
||||
DownHome?: string;
|
||||
Inside?: string;
|
||||
Outside?: string;
|
||||
Amount?: string;
|
||||
}
|
||||
|
||||
export interface TdxStockListResult {
|
||||
ErrorId: string | number;
|
||||
Value: Array<{ Code: string; Name: string }>;
|
||||
}
|
||||
|
||||
export interface IndexDefinition {
|
||||
code: string;
|
||||
name: string;
|
||||
@@ -25,3 +39,17 @@ export const CORE_INDEXES: readonly IndexDefinition[] = [
|
||||
{ code: '399001.SZ', name: '深证成指' },
|
||||
{ code: '399006.SZ', name: '创业板指' },
|
||||
];
|
||||
|
||||
/** TDX 快照对指数提供上涨/下跌家数,三个市场相加后作为首屏市场宽度口径。 */
|
||||
export const BREADTH_INDEXES: readonly IndexDefinition[] = [
|
||||
{ code: '000001.SH', name: '上证市场' },
|
||||
{ code: '399001.SZ', name: '深证市场' },
|
||||
{ code: '899050.BJ', name: '北证市场' },
|
||||
];
|
||||
|
||||
/** 首屏资金模块使用交易所指数成交额,避免把成交额趋势误称为主力净流入。 */
|
||||
export const TURNOVER_INDEXES: readonly IndexDefinition[] = [
|
||||
{ code: '000001.SH', name: '上证市场' },
|
||||
{ code: '399001.SZ', name: '深证市场' },
|
||||
{ code: '899050.BJ', name: '北证市场' },
|
||||
];
|
||||
|
||||
@@ -72,13 +72,32 @@ describe('市场概览 HTTP API', () => {
|
||||
expect(JSON.stringify(response.body)).not.toContain('127.0.0.1');
|
||||
});
|
||||
|
||||
it('未提供的板块能力返回可读的不可用状态', async () => {
|
||||
it('板块接口返回可点击的 TDX 一级行业对象', async () => {
|
||||
const client = {
|
||||
request: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ErrorId: '0',
|
||||
Value: [{ Code: '881319.SH', Name: '半导体' }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ErrorId: '0',
|
||||
Value: {
|
||||
'881319.SH': {
|
||||
Date: ['20260817', '20260818'],
|
||||
Open: ['100', '101'],
|
||||
High: ['102', '103'],
|
||||
Low: ['99', '100'],
|
||||
Close: ['100', '102'],
|
||||
Amount: ['1000', '1200'],
|
||||
Volume: ['100', '120'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
controllers: [MarketOverviewController],
|
||||
providers: [
|
||||
MarketOverviewService,
|
||||
{ provide: TDX_CLIENT_PORT, useValue: { request: vi.fn() } },
|
||||
],
|
||||
providers: [MarketOverviewService, { provide: TDX_CLIENT_PORT, useValue: client }],
|
||||
}).compile();
|
||||
app = moduleRef.createNestApplication();
|
||||
app.setGlobalPrefix('api/v1');
|
||||
@@ -88,7 +107,13 @@ describe('市场概览 HTTP API', () => {
|
||||
.get('/api/v1/market-overview/sectors')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toMatchObject({ source: 'tdx', status: 'unavailable', data: null });
|
||||
expect(response.body.warnings[0]).toContain('板块排行');
|
||||
expect(response.body).toMatchObject({
|
||||
source: 'tdx',
|
||||
status: 'fresh',
|
||||
data: {
|
||||
items: [expect.objectContaining({ code: '881319.SH', name: '半导体', changePercent: 2 })],
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(response.body)).not.toContain('127.0.0.1');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user